diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8ebd3bd..392ab39 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "Bash(python3:*)", "mcp__figma__get_figma_data", "mcp__figma__download_figma_images", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(grep:*)" ] } } diff --git a/index.css b/index.css index 40e2078..e214c77 100644 --- a/index.css +++ b/index.css @@ -765,6 +765,87 @@ white-space: nowrap; } +/* Sidebar Language Switch */ +.sidebar-language-switch { + padding: 0 1rem 0.75rem; + display: flex; + justify-content: flex-start; +} + +/* Toggle container */ +.lang-toggle { + position: relative; + display: flex; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; + padding: 2px; + width: 100%; + max-width: 120px; +} + +.lang-toggle-option { + flex: 1; + position: relative; + z-index: 1; + padding: 4px 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + color: rgba(255, 255, 255, 0.4); + background: none; + border: none; + cursor: pointer; + transition: color 0.3s ease; + text-align: center; +} + +.lang-toggle-option.active { + color: #ffffff; +} + +.lang-toggle-option:hover:not(.active) { + color: rgba(255, 255, 255, 0.6); +} + +/* Sliding indicator */ +.lang-toggle-slider { + position: absolute; + top: 2px; + bottom: 2px; + width: calc(50% - 2px); + background: rgba(255, 255, 255, 0.15); + border-radius: 999px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.lang-toggle-slider.left { + transform: translateX(2px); +} + +.lang-toggle-slider.right { + transform: translateX(calc(100% + 2px)); +} + +/* Collapsed state - simple button */ +.lang-toggle-collapsed { + padding: 4px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.08); + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s ease; +} + +.lang-toggle-collapsed:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.15); +} + /* Logout Button */ .logout-btn { width: 100%; @@ -6125,6 +6206,17 @@ object-fit: contain; } +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.header-actions .lang-toggle { + max-width: 100px; + padding: 2px; +} + .header-login-btn { padding: 10px 24px; border-radius: 999px; diff --git a/package-lock.json b/package-lock.json index b4bf9ab..60a8d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "castad---marketing-automation", "version": "0.0.0", "dependencies": { + "i18next": "^25.8.4", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "react-i18next": "^16.5.4" }, "devDependencies": { "@types/node": "^22.14.0", @@ -253,6 +255,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -1385,6 +1396,47 @@ "node": ">=6.9.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz", + "integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1533,6 +1585,33 @@ "react": "^19.2.3" } }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -1632,8 +1711,9 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1680,6 +1760,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -1756,6 +1845,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 9a5bec1..a5987f3 100755 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "i18next": "^25.8.4", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "react-i18next": "^16.5.4" }, "devDependencies": { "@types/node": "^22.14.0", diff --git a/src/App.tsx b/src/App.tsx index 70760f4..5976e0f 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ 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'; @@ -43,6 +44,7 @@ const initializeOnNewSession = () => { initializeOnNewSession(); const App: React.FC = () => { + const { t } = useTranslation(); const containerRef = useRef(null); // localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨) @@ -148,7 +150,7 @@ const App: React.FC = () => { } } catch (err) { console.error('Token callback failed:', err); - alert('로그인 처리에 실패했습니다. 다시 시도해주세요.'); + alert(t('app.loginFailed')); } finally { setIsProcessingCallback(false); } @@ -162,7 +164,7 @@ const App: React.FC = () => { window.location.href = response.redirect_url; } catch (err) { console.error('Kakao callback failed:', err); - alert('카카오 로그인에 실패했습니다. 다시 시도해주세요.'); + alert(t('app.kakaoLoginFailed')); // URL에서 code 파라미터 제거 const url = new URL(window.location.href); @@ -265,7 +267,7 @@ const App: React.FC = () => { // 응답 유효성 검사 if (!validateCrawlingResponse(data)) { - throw new Error('유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.'); + throw new Error(t('app.invalidUrl')); } setAnalysisData(data); @@ -273,7 +275,7 @@ const App: React.FC = () => { setViewMode('analysis'); } catch (err) { console.error('Crawling failed:', err); - const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.'; + const errorMessage = err instanceof Error ? err.message : t('app.analysisError'); setError(errorMessage); setViewMode('landing'); } @@ -289,7 +291,7 @@ const App: React.FC = () => { // 응답 유효성 검사 if (!validateCrawlingResponse(data)) { - throw new Error('업체 정보를 가져오는데 실패했습니다. 다시 시도해주세요.'); + throw new Error(t('app.autocompleteError')); } setAnalysisData(data); @@ -297,7 +299,7 @@ const App: React.FC = () => { setViewMode('analysis'); } catch (err) { console.error('Autocomplete failed:', err); - const errorMessage = err instanceof Error ? err.message : '업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.'; + const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError'); setError(errorMessage); setViewMode('landing'); } @@ -325,7 +327,7 @@ const App: React.FC = () => { window.location.href = response.auth_url; } catch (err) { console.error('Failed to get Kakao login URL:', err); - alert('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.'); + alert(t('app.loginUrlFailed')); } }; @@ -364,7 +366,7 @@ const App: React.FC = () => { return (
-

로그인 처리 중...

+

{t('app.loginProcessing')}

); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 875842f..93ea975 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; const Footer: React.FC = () => { + const { t } = useTranslation(); return ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 626c24a..f5ee1ef 100755 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,12 +1,15 @@ 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 = ({ onStartClick }) => { + const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); const loggedIn = isLoggedIn(); @@ -16,11 +19,10 @@ const Header: React.FC = ({ onStartClick }) => { 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('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.'); + alert(t('header.loginFailedAlert')); setIsLoading(false); } }; @@ -36,22 +38,25 @@ const Header: React.FC = ({ onStartClick }) => {
ADO2
- {loggedIn ? ( - - ) : ( - - )} +
+ + {loggedIn ? ( + + ) : ( + + )} +
); }; diff --git a/src/components/LanguageSwitch.tsx b/src/components/LanguageSwitch.tsx new file mode 100644 index 0000000..e41c9ba --- /dev/null +++ b/src/components/LanguageSwitch.tsx @@ -0,0 +1,50 @@ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface LanguageSwitchProps { + isCollapsed?: boolean; +} + +const LanguageSwitch: React.FC = ({ isCollapsed = false }) => { + const { i18n } = useTranslation(); + const isEnglish = i18n.language === 'en'; + + const switchTo = (lang: string) => { + if (i18n.language !== lang) { + i18n.changeLanguage(lang); + } + }; + + if (isCollapsed) { + return ( + + ); + } + + return ( +
+ + +
+
+ ); +}; + +export default LanguageSwitch; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d55cf4f..686e6a2 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,9 @@ import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { UserMeResponse } from '../types/api'; import { logout } from '../utils/api'; +import LanguageSwitch from './LanguageSwitch'; interface SidebarItemProps { icon: React.ReactNode; @@ -36,6 +38,7 @@ interface SidebarProps { } const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => { + const { t } = useTranslation(); const [isCollapsed, setIsCollapsed] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false); @@ -76,11 +79,11 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI }; const menuItems = [ - { id: '대시보드', label: '대시보드', disabled: false, icon: }, - { id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: }, - { id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: false, icon: }, - { id: '내 콘텐츠', label: '내 콘텐츠', disabled: true, icon: }, - { id: '내 정보', label: '내 정보', disabled: false, icon: }, + { id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: }, + { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: }, + { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: }, + { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: }, + { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: }, ]; return ( @@ -145,6 +148,10 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI
+
+ +
+
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? ( = ({ activeItem, onNavigate, onHome, userI )} {!isCollapsed && (
-

{userInfo?.nickname || '사용자'}

+

{userInfo?.nickname || t('sidebar.defaultUser')}

)}
@@ -175,7 +182,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI - {!isCollapsed && {isLoggingOut ? '로그아웃 중...' : '로그아웃'}} + {!isCollapsed && {isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}}
diff --git a/src/components/SocialPostingModal.tsx b/src/components/SocialPostingModal.tsx index 377663b..373f55b 100644 --- a/src/components/SocialPostingModal.tsx +++ b/src/components/SocialPostingModal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { getSocialAccounts, uploadToSocial, waitForUploadComplete, TokenExpiredError, handleSocialReconnect } from '../utils/api'; import { SocialAccount, VideoListItem, SocialUploadStatusResponse } from '../types/api'; import UploadProgressModal, { UploadStatus } from './UploadProgressModal'; @@ -30,6 +31,7 @@ const SocialPostingModal: React.FC = ({ onClose, video }) => { + const { t } = useTranslation(); const [socialAccounts, setSocialAccounts] = useState([]); const [selectedChannel, setSelectedChannel] = useState(''); const [title, setTitle] = useState(''); @@ -93,7 +95,7 @@ const SocialPostingModal: React.FC = ({ } } catch (error) { if (error instanceof TokenExpiredError) { - alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.'); + alert(t('social.youtubeExpiredAlert')); handleSocialReconnect(error.reconnectUrl); return; } @@ -105,20 +107,20 @@ const SocialPostingModal: React.FC = ({ const handlePost = async () => { if (!selectedChannel || !title.trim() || !video) { - alert('채널과 제목을 입력해주세요.'); + alert(t('social.channelAndTitleRequired')); return; } const selectedAcc = socialAccounts.find(acc => acc.platform_user_id === selectedChannel); if (!selectedAcc) { - alert('채널을 선택해주세요.'); + alert(t('social.selectChannel')); return; } // video.video_id 검증 - 반드시 존재해야 함 if (!video.video_id) { console.error('Video object missing video_id:', video); - alert('영상 정보가 올바르지 않습니다. (video_id 누락)'); + alert(t('social.invalidVideoInfo')); return; } @@ -157,7 +159,7 @@ const SocialPostingModal: React.FC = ({ const uploadResponse = await uploadToSocial(requestPayload); if (!uploadResponse.success) { - throw new Error(uploadResponse.message || '업로드 시작에 실패했습니다.'); + throw new Error(uploadResponse.message || t('social.uploadStartFailed')); } // Poll for upload completion @@ -180,13 +182,13 @@ const SocialPostingModal: React.FC = ({ } catch (error) { if (error instanceof TokenExpiredError) { setShowUploadProgress(false); - alert('YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.'); + alert(t('social.youtubeExpiredAlert')); handleSocialReconnect(error.reconnectUrl); return; } console.error('Upload failed:', error); setUploadStatus('failed'); - setUploadErrorMessage(error instanceof Error ? error.message : '업로드에 실패했습니다.'); + setUploadErrorMessage(error instanceof Error ? error.message : t('social.uploadFailed')); } finally { setIsPosting(false); } @@ -221,9 +223,9 @@ const SocialPostingModal: React.FC = ({ const selectedAccount = socialAccounts.find(acc => acc.platform_user_id === selectedChannel); const privacyOptions = [ - { value: 'public', label: '공개' }, - { value: 'unlisted', label: '미등록 (링크로만 접근)' }, - { value: 'private', label: '비공개' } + { value: 'public', label: t('social.privacyPublic') }, + { value: 'unlisted', label: t('social.privacyUnlisted') }, + { value: 'private', label: t('social.privacyPrivate') } ]; const selectedPrivacyOption = privacyOptions.find(opt => opt.value === privacy); @@ -253,7 +255,7 @@ const SocialPostingModal: React.FC = ({
e.stopPropagation()}> {/* Header */}
-

소셜 미디어 포스팅

+

{t('social.title')}