Compare commits
7 Commits
3b8298bba2
...
82b9fef3a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 82b9fef3a1 | |||
| 4fe065378f | |||
| bdb581459d | |||
| 63d60caabd | |||
| e400884eb7 | |||
| 3566c1f7bc | |||
| 9c0c3de35f |
BIN
public/assets/images/ado2_image.png
Normal file
BIN
public/assets/images/ado2_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
399
src/App.tsx
399
src/App.tsx
@ -1,16 +1,28 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Header from './components/Header';
|
||||
import HeroSection from './pages/Landing/HeroSection';
|
||||
import WelcomeSection from './pages/Landing/WelcomeSection';
|
||||
import DisplaySection from './pages/Landing/DisplaySection';
|
||||
import LoadingSection from './pages/Analysis/LoadingSection';
|
||||
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
||||
import LoginSection from './pages/Login/LoginSection';
|
||||
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
||||
import SocialConnectError from './pages/Social/SocialConnectError';
|
||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
||||
import VideoDetailPage from './components/VideoDetailPage';
|
||||
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
||||
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||
import { clearSessionStorage } from './utils/storageKeys';
|
||||
import { NAV } from './components/navItems';
|
||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
||||
import { CrawlingResponse } from './types/api';
|
||||
|
||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||
|
||||
const VIEW_MODE_KEY = 'castad_view_mode';
|
||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||
const SESSION_KEY = 'castad_session_active';
|
||||
|
||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||
@ -19,7 +31,15 @@ const initializeOnNewSession = () => {
|
||||
|
||||
if (!isExistingSession) {
|
||||
// 새 세션이면 localStorage 정리하고 세션 표시
|
||||
clearSessionStorage();
|
||||
localStorage.removeItem(VIEW_MODE_KEY);
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
localStorage.removeItem('castad_song_task_id');
|
||||
localStorage.removeItem('castad_image_task_id');
|
||||
localStorage.removeItem('castad_song_generation');
|
||||
localStorage.removeItem('castad_video_generation');
|
||||
localStorage.removeItem('castad_video_ratio');
|
||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||
}
|
||||
};
|
||||
@ -28,8 +48,61 @@ const initializeOnNewSession = () => {
|
||||
initializeOnNewSession();
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
|
||||
const { t, i18n } = useTranslation();
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
|
||||
// localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨)
|
||||
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
|
||||
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
|
||||
// 저장된 분석 데이터 파싱 및 유효성 검사
|
||||
const parseSavedAnalysisData = (): CrawlingResponse | null => {
|
||||
if (!savedAnalysisData) return null;
|
||||
try {
|
||||
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
|
||||
// 기본값 보장
|
||||
if (data.marketing_analysis) {
|
||||
data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
|
||||
location_feature_analysis: '',
|
||||
concept_scalability: ''
|
||||
};
|
||||
data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || {
|
||||
category_definition: '',
|
||||
core_value: ''
|
||||
};
|
||||
data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || [];
|
||||
data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || [];
|
||||
data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || [];
|
||||
}
|
||||
if (data.processed_info) {
|
||||
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
||||
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 || [];
|
||||
return data;
|
||||
} catch {
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로
|
||||
const getInitialViewMode = (): ViewMode => {
|
||||
if (savedViewMode === 'generation_flow') return 'generation_flow';
|
||||
if (isLoggedIn()) return 'generation_flow';
|
||||
return 'landing';
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
|
||||
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
|
||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
|
||||
parseSavedAnalysisData()
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||
|
||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||
@ -57,8 +130,8 @@ const App: React.FC = () => {
|
||||
setIsProcessingCallback(true);
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
||||
else {
|
||||
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
||||
else if (viewMode === 'landing') {
|
||||
trackViewContent();
|
||||
}
|
||||
}, []);
|
||||
@ -82,15 +155,21 @@ const App: React.FC = () => {
|
||||
await trackCompleteRegistration();
|
||||
}
|
||||
|
||||
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
|
||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (savedData) {
|
||||
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
||||
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
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;
|
||||
}
|
||||
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
|
||||
setInitialTab(NAV.HOME);
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
} catch (err) {
|
||||
console.error('Token callback failed:', err);
|
||||
alert(t('app.loginFailed'));
|
||||
@ -124,6 +203,231 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// viewMode 변경 시 localStorage에 저장
|
||||
useEffect(() => {
|
||||
localStorage.setItem(VIEW_MODE_KEY, viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || viewMode !== 'landing') return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = container.scrollTop;
|
||||
const sectionHeight = container.clientHeight;
|
||||
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
|
||||
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
|
||||
setScrollProgress(progress);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [viewMode]);
|
||||
|
||||
const scrollToSection = (index: number) => {
|
||||
if (containerRef.current) {
|
||||
const h = containerRef.current.clientHeight;
|
||||
containerRef.current.scrollTo({
|
||||
top: h * index,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 크롤링 응답 유효성 검사
|
||||
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
|
||||
// 필수 필드 존재 여부 확인
|
||||
if (!data) return false;
|
||||
if (!data.processed_info) return false;
|
||||
if (!data.marketing_analysis) return false;
|
||||
|
||||
// marketing_analysis 내부 필드 기본값 보장
|
||||
if (!data.marketing_analysis.brand_identity) {
|
||||
data.marketing_analysis.brand_identity = {
|
||||
location_feature_analysis: '',
|
||||
concept_scalability: ''
|
||||
};
|
||||
}
|
||||
if (!data.marketing_analysis.market_positioning) {
|
||||
data.marketing_analysis.market_positioning = {
|
||||
category_definition: '',
|
||||
core_value: ''
|
||||
};
|
||||
}
|
||||
if (!data.marketing_analysis.target_persona) {
|
||||
data.marketing_analysis.target_persona = [];
|
||||
}
|
||||
if (!data.marketing_analysis.selling_points) {
|
||||
data.marketing_analysis.selling_points = [];
|
||||
}
|
||||
if (!data.marketing_analysis.target_keywords) {
|
||||
data.marketing_analysis.target_keywords = [];
|
||||
}
|
||||
|
||||
// processed_info 내부 필드 기본값 보장
|
||||
if (!data.processed_info.customer_name) {
|
||||
data.processed_info.customer_name = '알 수 없음';
|
||||
}
|
||||
if (!data.processed_info.region) {
|
||||
data.processed_info.region = '';
|
||||
}
|
||||
if (!data.processed_info.detail_region_info) {
|
||||
data.processed_info.detail_region_info = '';
|
||||
}
|
||||
|
||||
// image_list 기본값 보장
|
||||
if (!data.image_list) {
|
||||
data.image_list = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStartAnalysis = async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
setAfterLoadTarget('analysis');
|
||||
setViewMode('loading');
|
||||
setIsAnalysisComplete(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await crawlUrl(url);
|
||||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error(t('app.invalidUrl'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
saveSearchHistory({ type: 'url', value: url });
|
||||
setIsAnalysisComplete(true);
|
||||
} catch (err) {
|
||||
console.error('Crawling failed:', err);
|
||||
setError(t('app.analysisError'));
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
// 업체명 자동완성으로 분석 시작
|
||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
||||
setAfterLoadTarget('analysis');
|
||||
setViewMode('loading');
|
||||
setIsAnalysisComplete(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await autocomplete(request);
|
||||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error(t('app.autocompleteError'));
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
|
||||
setIsAnalysisComplete(true);
|
||||
} catch (err) {
|
||||
console.error('Autocomplete failed:', err);
|
||||
setError(t('app.autocompleteError'));
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
||||
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
||||
setAfterLoadTarget('generation_flow');
|
||||
setViewMode('loading');
|
||||
setIsAnalysisComplete(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await marketingAnalysis(businessName, address, category);
|
||||
|
||||
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 tagged = { ...data, _isTestData: true };
|
||||
setAnalysisData(tagged);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||
setViewMode('analysis');
|
||||
};
|
||||
|
||||
// 언어 변경 시 테스트 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (!saved) return;
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (!parsed._isTestData) return;
|
||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
||||
fetch(jsonFile)
|
||||
.then(res => res.json())
|
||||
.then((data: CrawlingResponse) => {
|
||||
const tagged = { ...data, _isTestData: true };
|
||||
setAnalysisData(tagged);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||
})
|
||||
.catch(err => console.error('Failed to reload test data:', err));
|
||||
} catch { /* ignore */ }
|
||||
}, [i18n.language]);
|
||||
|
||||
const handleToLogin = async () => {
|
||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||
if (isLoggedIn()) {
|
||||
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
|
||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (savedData) {
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
}
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
|
||||
try {
|
||||
const { getKakaoLoginUrl } = await import('./utils/api');
|
||||
const response = await getKakaoLoginUrl();
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert(t('app.loginUrlFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
// localStorage 정리
|
||||
localStorage.removeItem(VIEW_MODE_KEY);
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
localStorage.removeItem('castad_wizard_step');
|
||||
localStorage.removeItem('castad_active_item');
|
||||
setViewMode('landing');
|
||||
};
|
||||
|
||||
// Social OAuth 콜백 페이지 처리
|
||||
const pathname = window.location.pathname;
|
||||
|
||||
@ -146,14 +450,12 @@ const App: React.FC = () => {
|
||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
|
||||
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
|
||||
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
|
||||
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
||||
if (ssulDetailMatch) {
|
||||
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
|
||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||
if (isProcessingCallback) {
|
||||
return (
|
||||
@ -165,8 +467,71 @@ const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
|
||||
return <GenerationFlow initialActiveItem={initialTab} />;
|
||||
if (viewMode === 'loading') {
|
||||
return (
|
||||
<LoadingSection
|
||||
isComplete={isAnalysisComplete}
|
||||
onComplete={() => setViewMode(afterLoadTarget)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'analysis' && analysisData) {
|
||||
return (
|
||||
<AnalysisResultSection
|
||||
onBack={handleGoBack}
|
||||
onGenerate={handleToLogin}
|
||||
data={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'login') {
|
||||
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
if (viewMode === 'generation_flow') {
|
||||
return (
|
||||
<GenerationFlow
|
||||
onHome={handleGoBack}
|
||||
initialActiveItem={initialTab}
|
||||
initialImageList={analysisData?.image_list || []}
|
||||
businessInfo={analysisData?.processed_info}
|
||||
initialAnalysisData={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인된 상태에서 "시작하기" 버튼 클릭
|
||||
const handleHeaderStart = () => {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="landing-container" ref={containerRef}>
|
||||
<Header onStartClick={handleHeaderStart} />
|
||||
<section className="landing-section">
|
||||
<HeroSection
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onManualInput={handleManualInput}
|
||||
onNext={() => scrollToSection(1)}
|
||||
error={error}
|
||||
scrollProgress={scrollProgress}
|
||||
/>
|
||||
</section>
|
||||
<section className="landing-section">
|
||||
<WelcomeSection
|
||||
onStartClick={() => scrollToSection(0)}
|
||||
onNext={() => scrollToSection(0)}
|
||||
/>
|
||||
</section>
|
||||
<section className="landing-section">
|
||||
<DisplaySection onStartClick={() => scrollToSection(0)} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toggleVideoLike, isLoggedIn } from '../utils/api';
|
||||
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
|
||||
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||
import { ContentType } from '../types/api';
|
||||
import LoginPromptModal from './LoginPromptModal';
|
||||
|
||||
@ -33,102 +33,29 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
const [likeCount, setLikeCount] = useState(initialLikeCount);
|
||||
const [isLiked, setIsLiked] = useState(initialIsLiked);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<{ top: number; bottom: number; left: number } | null>(null);
|
||||
|
||||
const shareBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const shareMenuRef = useRef<HTMLDivElement>(null);
|
||||
const likeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 종류별 공개 페이지가 다르다 — 합치면 id 가 겹치는 다른 콘텐츠가 열린다
|
||||
const shareUrl = `${window.location.origin}/${contentType === 'ssul' ? 'ssul' : 'video'}/${videoId}`;
|
||||
const shareUrl = buildContentShareUrl(API_URL, videoId, contentType);
|
||||
const shareTitle = storeName || t('videoDetail.kakaoDefaultTitle');
|
||||
const shareDescription = t('videoDetail.kakaoDescription', { region: region ?? '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareMenuOpen) return;
|
||||
const handleShareBtnClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const closeMenu = () => setShareMenuOpen(false);
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node) &&
|
||||
shareBtnRef.current && !shareBtnRef.current.contains(e.target as Node)
|
||||
) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
window.addEventListener('scroll', closeMenu, true);
|
||||
window.addEventListener('resize', closeMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('scroll', closeMenu, true);
|
||||
window.removeEventListener('resize', closeMenu);
|
||||
};
|
||||
}, [shareMenuOpen]);
|
||||
|
||||
// 메뉴가 뷰포트 밖으로 넘어가지 않도록 실제 렌더된 크기 기준으로 위치 보정
|
||||
useLayoutEffect(() => {
|
||||
if (!shareMenuOpen || !anchorRect || !shareMenuRef.current) return;
|
||||
const margin = 8;
|
||||
const menuRect = shareMenuRef.current.getBoundingClientRect();
|
||||
|
||||
let left = anchorRect.left;
|
||||
if (left + menuRect.width > window.innerWidth - margin) {
|
||||
left = window.innerWidth - menuRect.width - margin;
|
||||
const handled = await tryNativeShare({
|
||||
title: shareTitle,
|
||||
text: shareDescription,
|
||||
url: shareUrl,
|
||||
});
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
left = Math.max(margin, left);
|
||||
|
||||
let top = anchorRect.bottom + 6;
|
||||
if (top + menuRect.height > window.innerHeight - margin) {
|
||||
top = anchorRect.top - menuRect.height - 6;
|
||||
}
|
||||
top = Math.max(margin, top);
|
||||
|
||||
setMenuPos((prev) => (prev && prev.top === top && prev.left === left ? prev : { top, left }));
|
||||
}, [shareMenuOpen, anchorRect]);
|
||||
|
||||
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: storeName || t('videoDetail.kakaoDefaultTitle'),
|
||||
description: t('videoDetail.kakaoDescription', { region: region ?? '' }),
|
||||
imageUrl: 'https://ado2.o2osolution.ai/favicon_48.svg',
|
||||
link: { mobileWebUrl: shareUrl, webUrl: shareUrl },
|
||||
},
|
||||
buttons: [{ title: t('videoDetail.kakaoButtonTitle'), 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 handleLikeClick = (e: React.MouseEvent) => {
|
||||
@ -159,17 +86,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleShareBtnClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const rect = shareBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
// 초기 추정 위치(측정 전 첫 렌더용) — useLayoutEffect가 실제 크기 기준으로 다시 보정한다
|
||||
setAnchorRect({ top: rect.top, bottom: rect.bottom, left: rect.left });
|
||||
setMenuPos({ top: rect.bottom + 6, left: rect.left });
|
||||
}
|
||||
setShareMenuOpen((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="content-card-social" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
@ -190,7 +106,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
</span>
|
||||
|
||||
<button
|
||||
ref={shareBtnRef}
|
||||
className="content-card-share-btn"
|
||||
onClick={handleShareBtnClick}
|
||||
title={t('videoDetail.share')}
|
||||
@ -201,42 +116,6 @@ const ContentCardSocialActions: React.FC<ContentCardSocialActionsProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{shareMenuOpen && menuPos && createPortal(
|
||||
<div
|
||||
ref={shareMenuRef}
|
||||
className="video-detail-share-menu"
|
||||
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left }}
|
||||
>
|
||||
<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>
|
||||
{t('videoDetail.shareKakao')}
|
||||
</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>
|
||||
{t('videoDetail.shareFacebook')}
|
||||
</button>
|
||||
<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>
|
||||
{t('videoDetail.shareTwitter')}
|
||||
</button>
|
||||
<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 ? t('videoDetail.copied') : t('videoDetail.copyUrl')}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showLoginModal && (
|
||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||
)}
|
||||
|
||||
64
src/components/Header.tsx
Executable file
64
src/components/Header.tsx
Executable file
@ -0,0 +1,64 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
|
||||
import LanguageSwitch from './LanguageSwitch';
|
||||
|
||||
interface HeaderProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loggedIn = isLoggedIn();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert(t('header.loginFailedAlert'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (onStartClick) {
|
||||
onStartClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="landing-header">
|
||||
<div className="header-logo">
|
||||
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<LanguageSwitch />
|
||||
{loggedIn ? (
|
||||
<button
|
||||
className="header-start-btn"
|
||||
onClick={handleStart}
|
||||
>
|
||||
{t('header.start')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="header-login-btn"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('header.loading') : t('header.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@ -180,16 +180,24 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [isOpen]);
|
||||
|
||||
// 소셜 계정 로드
|
||||
// 소셜 계정 로드 + SEO 자동 채움
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (!isOpen) {
|
||||
loadedForTaskIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loadSocialAccounts();
|
||||
|
||||
// SEO 자동 채움 키. ADO2 는 task_id, 썰박스는 task_id 가 없어(빈 문자열)
|
||||
// (type, video_id) 로 키를 만든다 — 없으면 썰박스는 자동 채움이 통째로 스킵됐다.
|
||||
if (video?.title) {
|
||||
setTitle(video.title);
|
||||
setDescription(video.description || '');
|
||||
setTags((video.hashtags || []).join(','));
|
||||
setIsLoadingAutoDescription(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const seoKey =
|
||||
video?.type === 'ssul'
|
||||
? `ssul:${video.video_id}`
|
||||
@ -208,7 +216,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
setTags(cached.tags);
|
||||
}
|
||||
}
|
||||
}, [isOpen, video?.task_id, video?.type, video?.video_id]);
|
||||
}, [isOpen, video?.task_id, video?.type, video?.video_id, video?.title, video?.description, video?.hashtags]);
|
||||
|
||||
const loadSocialAccounts = async () => {
|
||||
setIsLoadingAccounts(true);
|
||||
@ -251,11 +259,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
content_type: video.type,
|
||||
task_id: isSsul ? String(video.video_id) : video.task_id,
|
||||
};
|
||||
// Call autoSEO API
|
||||
console.log('[Upload] Request payload:', requestPayload);
|
||||
const autoSeoResponse = await getAutoSeoYoutube(requestPayload);
|
||||
|
||||
// 각 필드가 있을 때만 덮어씌움 (기존 값 보호)
|
||||
if (autoSeoResponse.title) setTitle(autoSeoResponse.title);
|
||||
if (autoSeoResponse.description) setDescription(autoSeoResponse.description);
|
||||
if (autoSeoResponse.keywords) setTags(autoSeoResponse.keywords.join(','));
|
||||
@ -266,7 +271,6 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load autocomplete:', error);
|
||||
// 실패해도 사용자에게 별도 알림 없이 조용히 처리
|
||||
} finally {
|
||||
setIsLoadingAutoDescription(false);
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@ import {
|
||||
deleteComment,
|
||||
toggleVideoLike,
|
||||
isLoggedIn,
|
||||
getUserMe,
|
||||
API_URL,
|
||||
} from '../utils/api';
|
||||
import { VideoDetailItem, CommentItem } from '../types/api';
|
||||
import { VideoDetailItem, CommentItem, UserMeResponse } from '../types/api';
|
||||
import { buildVideoShareUrl, tryNativeShare } from '../utils/nativeShare';
|
||||
import LoginPromptModal from './LoginPromptModal';
|
||||
|
||||
interface VideoDetailContentProps {
|
||||
@ -30,7 +33,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||||
const [shareMenuUpward, setShareMenuUpward] = useState(false);
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
@ -43,16 +45,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
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 [currentUser, setCurrentUser] = useState<UserMeResponse | null>(null);
|
||||
|
||||
const fetchComments = useCallback(async (page: number, append = false) => {
|
||||
setCommentsLoading(true);
|
||||
@ -93,6 +86,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
fetchComments(1);
|
||||
}, [videoId, fetchComments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
getUserMe().then(setCurrentUser).catch((err) => {
|
||||
console.error('Failed to fetch current user:', err);
|
||||
});
|
||||
}, [authed]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return t('videoDetail.dateFormat', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
|
||||
@ -103,7 +103,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const shareUrl = `${window.location.origin}/video/${videoId}`;
|
||||
const shareUrl = buildVideoShareUrl(API_URL, videoId);
|
||||
const shareTitle = video?.store_name ?? t('videoDetail.kakaoDefaultTitle');
|
||||
const shareDescription = t('videoDetail.kakaoDescription', { region: video?.region ?? '' });
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
@ -115,6 +117,18 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleShareButtonClick = async () => {
|
||||
const handled = await tryNativeShare({
|
||||
title: shareTitle,
|
||||
text: shareDescription,
|
||||
url: shareUrl,
|
||||
});
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
setShareMenuOpen(v => !v);
|
||||
};
|
||||
|
||||
const handleKakaoShare = () => {
|
||||
const kakao = window.Kakao;
|
||||
if (kakao?.Share) {
|
||||
@ -159,23 +173,6 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [shareMenuOpen]);
|
||||
|
||||
// 모달 내부는 스크롤 영역(.video-detail-body)이 곧 화면 경계라, 버튼이 그 하단에
|
||||
// 가까우면 아래로 펼치는 메뉴가 잘려서 안 보인다. 열기 직전 남은 공간을 재서 뒤집는다.
|
||||
const handleToggleShareMenu = () => {
|
||||
if (!shareMenuOpen) {
|
||||
const btnRect = shareMenuRef.current?.getBoundingClientRect();
|
||||
if (btnRect) {
|
||||
const scrollBox = shareMenuRef.current?.closest('.video-detail-body');
|
||||
const boundaryBottom = scrollBox
|
||||
? scrollBox.getBoundingClientRect().bottom
|
||||
: window.innerHeight;
|
||||
const ESTIMATED_MENU_HEIGHT = 220;
|
||||
setShareMenuUpward(btnRect.bottom + ESTIMATED_MENU_HEIGHT > boundaryBottom);
|
||||
}
|
||||
}
|
||||
setShareMenuOpen(v => !v);
|
||||
};
|
||||
|
||||
const likeDebounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleLike = () => {
|
||||
@ -215,13 +212,11 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
if (!commentInput.trim() || commentSubmitting) return;
|
||||
setCommentSubmitting(true);
|
||||
try {
|
||||
await postVideoComment(videoId, commentInput.trim(), commentNickname);
|
||||
await postVideoComment(videoId, commentInput.trim());
|
||||
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);
|
||||
@ -310,7 +305,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
||||
<button
|
||||
className="video-detail-copy-btn"
|
||||
onClick={handleToggleShareMenu}
|
||||
onClick={handleShareButtonClick}
|
||||
>
|
||||
<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"/>
|
||||
@ -319,7 +314,7 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
{copied ? t('videoDetail.copied') : t('videoDetail.share')}
|
||||
</button>
|
||||
{shareMenuOpen && (
|
||||
<div className={`video-detail-share-menu ${shareMenuUpward ? 'upward' : ''}`}>
|
||||
<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">
|
||||
@ -362,25 +357,17 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
||||
</div>
|
||||
|
||||
{/* 댓글 작성자 프로필 선택 */}
|
||||
{authed && (
|
||||
{/* 댓글 작성자 프로필 (카카오 로그인 정보) */}
|
||||
{authed && currentUser && (
|
||||
<div className="video-detail-comment-profile">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
||||
alt={t('videoDetail.changeAvatarTitle')}
|
||||
className="video-detail-comment-avatar"
|
||||
onClick={handleChangeAvatar}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={t('videoDetail.changeAvatarTitle')}
|
||||
/>
|
||||
<input
|
||||
className="video-detail-nickname-input"
|
||||
type="text"
|
||||
placeholder={t('videoDetail.nicknamePlaceholder')}
|
||||
value={commentNickname}
|
||||
onChange={(e) => setCommentNickname(e.target.value)}
|
||||
maxLength={20}
|
||||
/>
|
||||
{currentUser.profile_image_url && (
|
||||
<img
|
||||
src={currentUser.profile_image_url}
|
||||
alt={currentUser.nickname}
|
||||
className="video-detail-comment-avatar"
|
||||
/>
|
||||
)}
|
||||
<span className="video-detail-comment-nickname">{currentUser.nickname}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -421,11 +408,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
<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"
|
||||
/>
|
||||
{c.profile_image_url && (
|
||||
<img
|
||||
src={c.profile_image_url}
|
||||
alt={c.nickname || t('videoDetail.anonymous')}
|
||||
className="video-detail-comment-avatar"
|
||||
/>
|
||||
)}
|
||||
<div className="video-detail-comment-body">
|
||||
<span className="video-detail-comment-nickname">
|
||||
{c.nickname || t('videoDetail.anonymous')}
|
||||
@ -450,11 +439,13 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
|
||||
<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"
|
||||
/>
|
||||
{r.profile_image_url && (
|
||||
<img
|
||||
src={r.profile_image_url}
|
||||
alt={r.nickname || t('videoDetail.anonymous')}
|
||||
className="video-detail-comment-avatar small"
|
||||
/>
|
||||
)}
|
||||
<div className="video-detail-comment-body">
|
||||
<span className="video-detail-comment-nickname">
|
||||
{r.nickname || t('videoDetail.anonymous')}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"newProject": "New Project",
|
||||
"newProject": "Create New Project",
|
||||
"ado2Contents": "ADO2 Contents",
|
||||
"myContents": "My Contents",
|
||||
"myInfo": "My Info",
|
||||
@ -21,9 +21,9 @@
|
||||
"credits": "Credits left: {{count}}",
|
||||
"loggingOut": "Logging out...",
|
||||
"logout": "Log Out",
|
||||
"inquiry": "Feedback",
|
||||
"login": "Log In",
|
||||
"settings": "Settings",
|
||||
"inquiry": "Feedback"
|
||||
"settings": "Settings"
|
||||
},
|
||||
"footer": {
|
||||
"company": "O2O Inc.",
|
||||
@ -158,29 +158,6 @@
|
||||
"loggingIn": "Logging in...",
|
||||
"kakaoStart": "Start with Kakao"
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "Content type",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "Ssulbox"
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "Select",
|
||||
"making": "Generating",
|
||||
"done": "Done"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · Pick a story",
|
||||
"step2": "STEP 2 · Pick a place",
|
||||
"searchPlaceholder": "Business name, or paste a Naver Map link",
|
||||
"searchButton": "Search",
|
||||
"searching": "Searching",
|
||||
"cost": "Cost",
|
||||
"costValue": "1 credit",
|
||||
"submit": "Create Ssulbox",
|
||||
"submitting": "Requesting…"
|
||||
}
|
||||
},
|
||||
"urlInput": {
|
||||
"searchTypeBusinessName": "Business Name",
|
||||
"placeholderBusinessName": "Enter a business name",
|
||||
@ -429,10 +406,10 @@
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "My Info",
|
||||
"tabContents": "My Contents",
|
||||
"tabCalendar": "Calendar",
|
||||
"tabPayment": "Payment",
|
||||
"tabBusiness": "SNS",
|
||||
"tabBasic": "Basic Info",
|
||||
"tabPayment": "Payment Info",
|
||||
"tabBusiness": "Social Channel Management",
|
||||
"basicPlaceholder": "Basic information settings are coming soon.",
|
||||
"paymentPlaceholder": "Payment information settings are coming soon.",
|
||||
"myBusiness": "My Business",
|
||||
"noBusinessTitle": "No registered business yet",
|
||||
@ -461,7 +438,9 @@
|
||||
"chargeSubmit": "Submit",
|
||||
"chargeSubmitting": "Submitting...",
|
||||
"chargeSuccess": "Your top-up request has been submitted!",
|
||||
"chargeConfirm": "OK"
|
||||
"chargeConfirm": "OK",
|
||||
"tabContents": "My Contents",
|
||||
"tabCalendar": "Calendar"
|
||||
},
|
||||
"ado2Contents": {
|
||||
"title": "ADO2 Contents",
|
||||
@ -545,8 +524,29 @@
|
||||
"scheduled": "Planned",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
||||
"days": ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],
|
||||
"months": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
],
|
||||
"days": [
|
||||
"Sun",
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat"
|
||||
],
|
||||
"yearMonth": "{{month}} {{year}}",
|
||||
"monthDay": "{{month}} {{day}}",
|
||||
"loading": "Loading...",
|
||||
@ -564,14 +564,13 @@
|
||||
},
|
||||
"loginPrompt": {
|
||||
"title": "Login Required",
|
||||
"description": "Sign in with Kakao in a few seconds\nto keep going.",
|
||||
"loginBtn": "Continue with Kakao"
|
||||
"loginBtn": "Login",
|
||||
"description": "Sign in with Kakao in a few seconds\nto keep going."
|
||||
},
|
||||
"videoDetail": {
|
||||
"dateFormat": "{{month}}/{{day}}/{{year}}",
|
||||
"kakaoDefaultTitle": "ADO2 Video",
|
||||
"kakaoDescription": "{{region}} · ADO2 AI Marketing Video",
|
||||
"kakaoButtonTitle": "Watch Video",
|
||||
"deletedComment": "(This comment has been deleted.)",
|
||||
"closeAriaLabel": "Close",
|
||||
"share": "Share",
|
||||
@ -581,8 +580,6 @@
|
||||
"shareTwitter": "X (Twitter)",
|
||||
"copyUrl": "Copy URL",
|
||||
"commentsTitle": "Comments",
|
||||
"changeAvatarTitle": "Click to change avatar",
|
||||
"nicknamePlaceholder": "Author name",
|
||||
"commentPlaceholder": "Write a comment...",
|
||||
"commentLoginRequired": "Please log in to write a comment",
|
||||
"commentSubmitting": "Submitting",
|
||||
@ -603,5 +600,28 @@
|
||||
"autocompleteError": "No results found. Please check your input and try again.",
|
||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||
"pageComingSoon": "{{page}} page is coming soon."
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "Content type",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "Ssulbox"
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "Select",
|
||||
"making": "Generating",
|
||||
"done": "Done"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · Pick a story",
|
||||
"step2": "STEP 2 · Pick a place",
|
||||
"searchPlaceholder": "Business name, or paste a Naver Map link",
|
||||
"searchButton": "Search",
|
||||
"searching": "Searching",
|
||||
"cost": "Cost",
|
||||
"costValue": "1 credit",
|
||||
"submit": "Create Ssulbox",
|
||||
"submitting": "Requesting…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,12 +21,12 @@
|
||||
"credits": "보유 크레딧: {{count}}",
|
||||
"loggingOut": "로그아웃 중...",
|
||||
"logout": "로그아웃",
|
||||
"inquiry": "고객의견",
|
||||
"login": "로그인",
|
||||
"settings": "설정",
|
||||
"inquiry": "고객의견"
|
||||
"settings": "설정"
|
||||
},
|
||||
"footer": {
|
||||
"company":"㈜에이아이오투오",
|
||||
"company": "㈜에이아이오투오",
|
||||
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
|
||||
"headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||
"researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||
@ -109,7 +109,8 @@
|
||||
"close": "닫기",
|
||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||
"goToCalendar": "캘린더에서 확인",
|
||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다." },
|
||||
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
||||
},
|
||||
"landing": {
|
||||
"hero": {
|
||||
"searchTypeLabel": "업체명 | URL",
|
||||
@ -157,78 +158,6 @@
|
||||
"loggingIn": "로그인 중...",
|
||||
"kakaoStart": "카카오로 시작하기"
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "생성할 콘텐츠 종류",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "썰박스"
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "선택",
|
||||
"making": "생성",
|
||||
"done": "완성"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · 시나리오 선택",
|
||||
"step2": "STEP 2 · 업장 검색",
|
||||
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
||||
"searchButton": "검색",
|
||||
"searching": "검색 중",
|
||||
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
||||
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
|
||||
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
|
||||
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
|
||||
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
||||
"cost": "생성 비용",
|
||||
"costValue": "1 크레딧",
|
||||
"submit": "썰박스 만들기",
|
||||
"submitting": "요청 중…"
|
||||
},
|
||||
"making": {
|
||||
"title": "썰박스를 만들고 있어요",
|
||||
"step1": "대본 생성 중",
|
||||
"step2": "스토리보드 구성 중",
|
||||
"step3": "이미지·목소리 생성 중",
|
||||
"step4": "영상 합성 중",
|
||||
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
|
||||
"failTitle": "생성에 실패했어요",
|
||||
"failDefault": "잠시 후 다시 시도해주세요.",
|
||||
"refunded": "차감된 크레딧은 환불되었습니다.",
|
||||
"retry": "다시 만들기"
|
||||
},
|
||||
"result": {
|
||||
"title": "썰박스가 완성됐어요",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
|
||||
"scenarioLabel": "시나리오",
|
||||
"createdLabel": "생성일시"
|
||||
},
|
||||
"viewer": {
|
||||
"untitled": "이름 없는 콘텐츠"
|
||||
},
|
||||
"error": {
|
||||
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
|
||||
},
|
||||
"scenario": {
|
||||
"joseon": {
|
||||
"name": "조선왕",
|
||||
"desc": "조선 27대 왕들의 실화 썰"
|
||||
},
|
||||
"samgukji": {
|
||||
"name": "삼국지",
|
||||
"desc": "위·촉·오 영웅 28인의 야사"
|
||||
},
|
||||
"greek": {
|
||||
"name": "그리스·로마신화",
|
||||
"desc": "올림포스 신·영웅들의 전설"
|
||||
},
|
||||
"odyssey": {
|
||||
"name": "오디세이",
|
||||
"desc": "오디세우스 10년 귀향 대모험"
|
||||
}
|
||||
}
|
||||
},
|
||||
"urlInput": {
|
||||
"searchTypeBusinessName": "업체명",
|
||||
"placeholderBusinessName": "업체명을 입력하세요.",
|
||||
@ -477,10 +406,10 @@
|
||||
},
|
||||
"myInfo": {
|
||||
"title": "내 정보",
|
||||
"tabContents": "내 콘텐츠",
|
||||
"tabCalendar": "콘텐츠 캘린더",
|
||||
"tabBasic": "기본 정보",
|
||||
"tabPayment": "결제 정보",
|
||||
"tabBusiness": "소셜 채널 관리",
|
||||
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
|
||||
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
|
||||
"myBusiness": "내 비즈니스",
|
||||
"noBusinessTitle": "아직 등록된 비즈니스가 없어요",
|
||||
@ -509,7 +438,9 @@
|
||||
"chargeSubmit": "요청하기",
|
||||
"chargeSubmitting": "요청 중...",
|
||||
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
||||
"chargeConfirm": "확인"
|
||||
"chargeConfirm": "확인",
|
||||
"tabContents": "내 콘텐츠",
|
||||
"tabCalendar": "콘텐츠 캘린더"
|
||||
},
|
||||
"ado2Contents": {
|
||||
"title": "ADO2 콘텐츠",
|
||||
@ -593,8 +524,29 @@
|
||||
"scheduled": "예약",
|
||||
"failed": "실패"
|
||||
},
|
||||
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
||||
"days": ["일","월","화","수","목","금","토"],
|
||||
"months": [
|
||||
"1월",
|
||||
"2월",
|
||||
"3월",
|
||||
"4월",
|
||||
"5월",
|
||||
"6월",
|
||||
"7월",
|
||||
"8월",
|
||||
"9월",
|
||||
"10월",
|
||||
"11월",
|
||||
"12월"
|
||||
],
|
||||
"days": [
|
||||
"일",
|
||||
"월",
|
||||
"화",
|
||||
"수",
|
||||
"목",
|
||||
"금",
|
||||
"토"
|
||||
],
|
||||
"yearMonth": "{{year}}년 {{month}}",
|
||||
"monthDay": "{{month}} {{day}}일",
|
||||
"loading": "불러오는 중...",
|
||||
@ -612,14 +564,13 @@
|
||||
},
|
||||
"loginPrompt": {
|
||||
"title": "로그인이 필요합니다.",
|
||||
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요.",
|
||||
"loginBtn": "카카오 로그인"
|
||||
"loginBtn": "로그인",
|
||||
"description": "카카오 계정으로 간편하게 로그인하고\n이어서 진행해보세요."
|
||||
},
|
||||
"videoDetail": {
|
||||
"dateFormat": "{{year}}년 {{month}}월 {{day}}일",
|
||||
"kakaoDefaultTitle": "ADO2 영상",
|
||||
"kakaoDescription": "{{region}} · ADO2 AI 마케팅 영상",
|
||||
"kakaoButtonTitle": "영상 보기",
|
||||
"deletedComment": "(삭제된 댓글입니다.)",
|
||||
"closeAriaLabel": "닫기",
|
||||
"share": "공유하기",
|
||||
@ -629,8 +580,6 @@
|
||||
"shareTwitter": "X (트위터)",
|
||||
"copyUrl": "URL 복사",
|
||||
"commentsTitle": "댓글",
|
||||
"changeAvatarTitle": "클릭하여 아바타 변경",
|
||||
"nicknamePlaceholder": "작성자 이름",
|
||||
"commentPlaceholder": "댓글을 입력하세요...",
|
||||
"commentLoginRequired": "로그인 후 댓글을 작성할 수 있습니다",
|
||||
"commentSubmitting": "작성 중",
|
||||
@ -651,5 +600,77 @@
|
||||
"autocompleteError": "검색 정보를 찾을 수 없습니다. 입력 정보를 다시 확인해주세요.",
|
||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||
},
|
||||
"pipelineTabs": {
|
||||
"ariaLabel": "생성할 콘텐츠 종류",
|
||||
"ado2": "ADO2",
|
||||
"ssul": "썰박스"
|
||||
},
|
||||
"ssulbox": {
|
||||
"steps": {
|
||||
"pick": "선택",
|
||||
"making": "생성",
|
||||
"done": "완성"
|
||||
},
|
||||
"create": {
|
||||
"step1": "STEP 1 · 시나리오 선택",
|
||||
"step2": "STEP 2 · 업장 검색",
|
||||
"searchPlaceholder": "업체명 또는 URL을 입력하세요.",
|
||||
"searchButton": "검색",
|
||||
"searching": "검색 중",
|
||||
"placeLoading": "가게를 찾는 중… (몇 초 걸려요)",
|
||||
"hintUrlOk": "네이버 링크로 이 가게를 직접 크롤링합니다.",
|
||||
"hintDefault": "가게를 선택하면 그 가게의 사진과 정보를 크롤링해 썰박스에 씁니다.",
|
||||
"hintScenario": "{{name}} 시나리오로 약 5~6분 소요됩니다.",
|
||||
"noResult": "검색 결과가 없어요. 이름을 바꾸거나 네이버 지도 링크를 붙여넣어 주세요.",
|
||||
"cost": "생성 비용",
|
||||
"costValue": "1 크레딧",
|
||||
"submit": "썰박스 만들기",
|
||||
"submitting": "요청 중…"
|
||||
},
|
||||
"making": {
|
||||
"title": "썰박스를 만들고 있어요",
|
||||
"step1": "대본 생성 중",
|
||||
"step2": "스토리보드 구성 중",
|
||||
"step3": "이미지·목소리 생성 중",
|
||||
"step4": "영상 합성 중",
|
||||
"takesTime": "약 5~6분 소요됩니다. 다른 작업을 하셔도 계속 진행됩니다.",
|
||||
"failTitle": "생성에 실패했어요",
|
||||
"failDefault": "잠시 후 다시 시도해주세요.",
|
||||
"refunded": "차감된 크레딧은 환불되었습니다.",
|
||||
"retry": "다시 만들기"
|
||||
},
|
||||
"result": {
|
||||
"title": "썰박스가 완성됐어요",
|
||||
"download": "다운로드",
|
||||
"downloading": "다운로드 중...",
|
||||
"loadFailed": "영상을 불러오지 못했어요. 내 콘텐츠에서 확인해주세요.",
|
||||
"scenarioLabel": "시나리오",
|
||||
"createdLabel": "생성일시"
|
||||
},
|
||||
"viewer": {
|
||||
"untitled": "이름 없는 콘텐츠"
|
||||
},
|
||||
"error": {
|
||||
"createFailed": "생성 요청에 실패했어요. 잠시 후 다시 시도해주세요."
|
||||
},
|
||||
"scenario": {
|
||||
"joseon": {
|
||||
"name": "조선왕",
|
||||
"desc": "조선 27대 왕들의 실화 썰"
|
||||
},
|
||||
"samgukji": {
|
||||
"name": "삼국지",
|
||||
"desc": "위·촉·오 영웅 28인의 야사"
|
||||
},
|
||||
"greek": {
|
||||
"name": "그리스·로마신화",
|
||||
"desc": "올림포스 신·영웅들의 전설"
|
||||
},
|
||||
"odyssey": {
|
||||
"name": "오디세이",
|
||||
"desc": "오디세우스 10년 귀향 대모험"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +163,15 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
}
|
||||
>
|
||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||
{video.thumbnail_url ? (
|
||||
{video.poster_url ? (
|
||||
<img
|
||||
src={video.poster_url}
|
||||
alt={video.store_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="content-video-preview"
|
||||
/>
|
||||
) : video.thumbnail_url ? (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.store_name}
|
||||
|
||||
@ -478,6 +478,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||
created_at: new Date().toISOString(),
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
is_liked_by_me: false,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@ -79,6 +79,7 @@ interface BusinessInfo {
|
||||
}
|
||||
|
||||
interface GenerationFlowProps {
|
||||
onHome?: () => void;
|
||||
initialActiveItem?: string;
|
||||
initialImageList?: ImageListItem[];
|
||||
businessInfo?: BusinessInfo;
|
||||
@ -94,6 +95,7 @@ interface GenerationFlowProps {
|
||||
// 3: 완료 (Completion)
|
||||
|
||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onHome,
|
||||
initialActiveItem = NAV.HOME,
|
||||
initialImageList = [],
|
||||
businessInfo,
|
||||
@ -311,7 +313,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setImageList(prev => [...newImages, ...prev]);
|
||||
};
|
||||
|
||||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 ADO2 콘텐츠 갤러리로 이동
|
||||
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고, 부모가 있으면 랜딩으로 돌아간다.
|
||||
const handleHome = () => {
|
||||
clearProjectStorage();
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
@ -321,6 +323,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
setAnalysisData(null);
|
||||
revokeAllPreviews(imageListRef.current);
|
||||
setImageList([]);
|
||||
if (onHome) {
|
||||
onHome();
|
||||
return;
|
||||
}
|
||||
setActiveItem(NAV.HOME);
|
||||
};
|
||||
|
||||
|
||||
@ -264,7 +264,15 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
|
||||
: setSelectedVideoId(video.video_id)
|
||||
}
|
||||
>
|
||||
{video.result_movie_url ? (
|
||||
{video.poster_url ? (
|
||||
<img
|
||||
src={video.poster_url}
|
||||
alt={video.store_name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="content-video-preview"
|
||||
/>
|
||||
) : video.result_movie_url ? (
|
||||
<VideoPreviewCard
|
||||
src={video.result_movie_url}
|
||||
className="content-video-preview"
|
||||
|
||||
51
src/pages/Landing/DisplaySection.tsx
Executable file
51
src/pages/Landing/DisplaySection.tsx
Executable file
@ -0,0 +1,51 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Footer from '../../components/Footer';
|
||||
|
||||
interface DisplaySectionProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
||||
const { t } = useTranslation();
|
||||
// YouTube Shorts 영상 ID들
|
||||
const videos = [
|
||||
{ id: 1, videoId: 'M3iuPZ59X1I' },
|
||||
{ id: 2, videoId: 'JxWQxELDHSs' },
|
||||
{ id: 3, videoId: 'c2ZdwhaB7S4' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="display-section">
|
||||
<div className="content-safe-area">
|
||||
{/* Main visual frames container */}
|
||||
<div className="display-frames">
|
||||
{videos.map((video, index) => (
|
||||
<div
|
||||
key={video.id}
|
||||
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={`YouTube Shorts ${video.id}`}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="display-video"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button onClick={onStartClick} className="display-button">
|
||||
{t('landing.display.startButton')}
|
||||
</button>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisplaySection;
|
||||
176
src/pages/Landing/HeroSection.tsx
Executable file
176
src/pages/Landing/HeroSection.tsx
Executable file
@ -0,0 +1,176 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
||||
import SearchInputForm from '../../components/SearchInputForm';
|
||||
import { SearchType } from '../../components/SearchInputForm';
|
||||
|
||||
// Orb configuration with movement zones to prevent overlap
|
||||
interface OrbConfig {
|
||||
id: string;
|
||||
size: number;
|
||||
initialX: number;
|
||||
initialY: number;
|
||||
color: string;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
const orbConfigs: OrbConfig[] = [
|
||||
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
||||
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
||||
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
||||
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
||||
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, 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 },
|
||||
];
|
||||
|
||||
interface HeroSectionProps {
|
||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
||||
onNext?: () => void;
|
||||
error?: string | null;
|
||||
scrollProgress?: number;
|
||||
}
|
||||
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationRefs = useRef<number[]>([]);
|
||||
|
||||
// Orb 랜덤 이동 애니메이션
|
||||
useEffect(() => {
|
||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
||||
const config = orbConfigs[index];
|
||||
let currentX = config.initialX;
|
||||
let currentY = config.initialY;
|
||||
let targetX = currentX;
|
||||
let targetY = currentY;
|
||||
let scale = 1;
|
||||
let targetScale = 1;
|
||||
let isFirstMove = true;
|
||||
|
||||
const generateNewTarget = () => {
|
||||
const rangeX = config.maxX - config.minX;
|
||||
const rangeY = config.maxY - config.minY;
|
||||
if (isFirstMove) {
|
||||
const smallRangeX = rangeX * 0.3;
|
||||
const smallRangeY = rangeY * 0.3;
|
||||
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
||||
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
||||
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
||||
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
||||
isFirstMove = false;
|
||||
} else {
|
||||
targetX = config.minX + Math.random() * rangeX;
|
||||
targetY = config.minY + Math.random() * rangeY;
|
||||
}
|
||||
targetScale = 0.9 + Math.random() * 0.2;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
const speed = 0.003;
|
||||
currentX += (targetX - currentX) * speed;
|
||||
currentY += (targetY - currentY) * speed;
|
||||
scale += (targetScale - scale) * speed;
|
||||
orb.style.left = `${currentX}%`;
|
||||
orb.style.top = `${currentY}%`;
|
||||
orb.style.transform = `scale(${scale})`;
|
||||
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
|
||||
if (distance < 1) generateNewTarget();
|
||||
animationRefs.current[index] = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
generateNewTarget();
|
||||
animate();
|
||||
};
|
||||
|
||||
orbRefs.current.forEach((orb, index) => {
|
||||
if (orb) moveOrb(orb, index);
|
||||
});
|
||||
|
||||
return () => {
|
||||
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hero-section">
|
||||
{/* Animated background orbs */}
|
||||
<div
|
||||
className="hero-bg-orbs"
|
||||
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
||||
>
|
||||
{orbConfigs.map((config, index) => (
|
||||
<div
|
||||
key={config.id}
|
||||
ref={el => { orbRefs.current[index] = el; }}
|
||||
className="hero-orb-random"
|
||||
style={{
|
||||
width: `${config.size}px`,
|
||||
height: `${config.size}px`,
|
||||
background: config.color,
|
||||
left: `${config.initialX}%`,
|
||||
top: `${config.initialY}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hero-content">
|
||||
<img
|
||||
src="/assets/images/ADO2_with Slogan_white.svg"
|
||||
alt="ADO2"
|
||||
className="hero-logo"
|
||||
/>
|
||||
|
||||
<SearchInputForm
|
||||
onAnalyze={onAnalyze}
|
||||
onAutocomplete={onAutocomplete}
|
||||
onManualInput={onManualInput}
|
||||
onManualButtonClick={() => {
|
||||
if (isLoggedIn()) {
|
||||
setIsManualModalOpen(true);
|
||||
} else {
|
||||
setIsLoginPromptOpen(true);
|
||||
}
|
||||
}}
|
||||
error={externalError || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Indicator */}
|
||||
<button onClick={onNext} className="scroll-indicator">
|
||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
||||
<div className="scroll-indicator-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 10l5 5 5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isManualModalOpen && (
|
||||
<BusinessNameInputModal
|
||||
onClose={() => setIsManualModalOpen(false)}
|
||||
onSubmit={(businessName, address, category) => {
|
||||
setIsManualModalOpen(false);
|
||||
onManualInput?.(businessName, address, category);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoginPromptOpen && (
|
||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
70
src/pages/Landing/WelcomeSection.tsx
Executable file
70
src/pages/Landing/WelcomeSection.tsx
Executable file
@ -0,0 +1,70 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WelcomeSectionProps {
|
||||
onStartClick?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const features = [
|
||||
{
|
||||
id: 1,
|
||||
title: t('landing.welcome.feature1Title'),
|
||||
description: t('landing.welcome.feature1Desc'),
|
||||
iconBg: '#9BCACC',
|
||||
icon: '/assets/images/icon-analysis.svg'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: t('landing.welcome.feature2Title'),
|
||||
description: t('landing.welcome.feature2Desc'),
|
||||
iconBg: '#DFC7FD',
|
||||
icon: '/assets/images/icon-content.svg'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: t('landing.welcome.feature3Title'),
|
||||
description: t('landing.welcome.feature3Desc'),
|
||||
iconBg: '#D4FDF3',
|
||||
icon: '/assets/images/icon-deploy.svg'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="welcome-section">
|
||||
<div className="welcome-content">
|
||||
{/* Star Icon - Top Center */}
|
||||
<div className="welcome-star">
|
||||
<img src="/assets/images/star-icon.svg" alt="star" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="welcome-header">
|
||||
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
|
||||
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="feature-grid">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.id} className="feature-card">
|
||||
<div className="feature-number-badge">
|
||||
<span>{feature.id}</span>
|
||||
</div>
|
||||
<h3 className="feature-card-title">{feature.title}</h3>
|
||||
<div className="feature-icon-box" style={{ backgroundColor: feature.iconBg }}>
|
||||
<img src={feature.icon} alt={feature.title} className="feature-icon-img" />
|
||||
</div>
|
||||
<p className="feature-card-description">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeSection;
|
||||
109
src/pages/Login/LoginSection.tsx
Executable file
109
src/pages/Login/LoginSection.tsx
Executable file
@ -0,0 +1,109 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
|
||||
|
||||
interface LoginSectionProps {
|
||||
onBack: () => void;
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (code) {
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKakaoCallback = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await kakaoCallback(code);
|
||||
|
||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
||||
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
|
||||
if (response.is_new_user) {
|
||||
await trackCompleteRegistration();
|
||||
}
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
|
||||
// 로그인 성공
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
setError(t('login.kakaoLoginFailed'));
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKakaoLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
|
||||
// 카카오 로그인 페이지로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
setError(t('login.loginUrlFailed'));
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
{/* Back Button */}
|
||||
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
|
||||
<img src="/assets/images/icon-back.svg" alt="Back" />
|
||||
<span>{t('login.back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="login-content">
|
||||
{/* Logo */}
|
||||
<div className="login-logo">
|
||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kakao Login Button */}
|
||||
<button
|
||||
onClick={handleKakaoLogin}
|
||||
className="btn-kakao"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginSection;
|
||||
@ -247,7 +247,7 @@ const SsulDetailContent: React.FC<SsulDetailContentProps> = ({
|
||||
if (!commentInput.trim() || commentSubmitting) return;
|
||||
setCommentSubmitting(true);
|
||||
try {
|
||||
await postVideoComment(contentId, commentInput.trim(), commentNickname, undefined, 'ssul');
|
||||
await postVideoComment(contentId, commentInput.trim(), undefined, 'ssul');
|
||||
setCommentInput('');
|
||||
if (commentTextareaRef.current) {
|
||||
commentTextareaRef.current.style.height = 'auto';
|
||||
|
||||
@ -2886,6 +2886,7 @@
|
||||
.wizard-stepper {
|
||||
padding: 2rem;
|
||||
max-width: 100%;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.wizard-stepper-node {
|
||||
|
||||
@ -1145,6 +1145,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 모바일: 제목은 한 줄로 유지하고, 권장 수량/카운트는 그 아래 줄로 내린다 */
|
||||
@media (max-width: 1023px) {
|
||||
.asset-section-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.asset-section-header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.asset-section-header-left .asset-section-title {
|
||||
order: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-section-header-left .asset-section-count {
|
||||
order: 2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-section-header-left .asset-section-subtitle {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Asset Image List */
|
||||
.asset-image-list {
|
||||
background-color: #002224;
|
||||
|
||||
@ -287,17 +287,22 @@ export interface VideoListItem {
|
||||
region: string;
|
||||
task_id: string;
|
||||
result_movie_url: string;
|
||||
poster_url?: string | null;
|
||||
thumbnail_url?: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
hashtags?: string[] | null;
|
||||
created_at: string;
|
||||
like_count: number;
|
||||
comment_count: number;
|
||||
is_liked_by_me?: boolean;
|
||||
is_liked_by_me: boolean;
|
||||
}
|
||||
|
||||
// 비디오 상세 아이템
|
||||
export interface VideoDetailItem {
|
||||
video_id: number;
|
||||
result_movie_url: string;
|
||||
poster_url?: string | null;
|
||||
store_name: string;
|
||||
region: string;
|
||||
created_at: string;
|
||||
@ -320,6 +325,7 @@ export interface VideosListResponse {
|
||||
export interface CommentReply {
|
||||
id: number;
|
||||
nickname: string;
|
||||
profile_image_url: string | null;
|
||||
content: string | null;
|
||||
is_deleted: boolean;
|
||||
is_mine: boolean;
|
||||
@ -330,6 +336,7 @@ export interface CommentReply {
|
||||
export interface CommentItem {
|
||||
id: number;
|
||||
nickname: string;
|
||||
profile_image_url: string | null;
|
||||
content: string | null;
|
||||
is_deleted: boolean;
|
||||
is_mine: boolean;
|
||||
|
||||
@ -658,18 +658,17 @@ export async function getVideoComments(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 댓글 작성
|
||||
// 댓글 작성 (작성자 닉네임/프로필은 서버가 로그인된 카카오 정보로 채움)
|
||||
export async function postVideoComment(
|
||||
videoId: string,
|
||||
content: string,
|
||||
nickname?: string,
|
||||
parentId?: number,
|
||||
contentType: ContentType = 'video'
|
||||
): Promise<CommentItem> {
|
||||
const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?type=${contentType}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
|
||||
body: JSON.stringify({ content, parent_id: parentId ?? null }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
39
src/utils/nativeShare.ts
Normal file
39
src/utils/nativeShare.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 기기의 네이티브 공유 시트를 열고 요청을 처리했는지 반환합니다.
|
||||
*
|
||||
* 사용자가 공유 시트를 닫은 경우도 정상적으로 처리된 것으로 간주합니다.
|
||||
*/
|
||||
export async function tryNativeShare(data: ShareData): Promise<boolean> {
|
||||
if (typeof navigator.share !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** API 서버가 제공하는 영상별 Open Graph 공유 페이지 URL을 만듭니다. */
|
||||
export function buildVideoShareUrl(apiBaseUrl: string, videoId: number | string): string {
|
||||
return `${apiBaseUrl.replace(/\/$/, '')}/video/share/${encodeURIComponent(String(videoId))}`;
|
||||
}
|
||||
|
||||
/** 콘텐츠 종류별 공개 공유 URL. video.id 와 ssul_content.id 가 겹치므로 경로를 갈라야 한다. */
|
||||
export function buildContentShareUrl(
|
||||
apiBaseUrl: string,
|
||||
contentId: number | string,
|
||||
contentType: 'video' | 'ssul' = 'video',
|
||||
): string {
|
||||
if (contentType === 'ssul') {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${origin}/ssul/${encodeURIComponent(String(contentId))}`;
|
||||
}
|
||||
return buildVideoShareUrl(apiBaseUrl, contentId);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user