diff --git a/README.md b/README.md index 5312272..0ac8335 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Today I Learned Alarm +# RecallBuddy 매일 학습한 내용을 정리하고 알림을 받는 앱입니다. diff --git a/app/index.html b/app/index.html index 6a2fbd8..9526915 100644 --- a/app/index.html +++ b/app/index.html @@ -4,7 +4,7 @@ - Today I Learned Alarm + RecallBuddy
diff --git a/app/public/character.png b/app/public/character.png new file mode 100644 index 0000000..f5949a1 Binary files /dev/null and b/app/public/character.png differ diff --git a/app/public/onboarding.png b/app/public/onboarding.png new file mode 100644 index 0000000..4e71584 Binary files /dev/null and b/app/public/onboarding.png differ diff --git a/app/src/App.tsx b/app/src/App.tsx index 70aae17..d04b4e4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,14 +1,16 @@ import React, { useEffect, useState } from 'react'; import { initDB } from "react-indexed-db-hook"; import { onAuthStateChanged, User } from 'firebase/auth'; +import { doc, getDoc } from 'firebase/firestore'; import { DBConfig } from './DBConfig'; -import { auth } from './firebase'; +import { auth, db } from './firebase'; import FlashCardViewer from './pages/FlashCardViewer'; import Login from './pages/Login'; import Settings from './pages/Settings'; import NoDataView from './pages/NoDataView'; -import UserDropdown from './widgets/UserDropdown'; +import Onboarding from './pages/Onboarding'; +import Card from './components/Card'; import { useTodayFlashcards } from './hooks/useTodayFlashcards'; import { useNavigationStore } from './stores/navigationStore'; @@ -18,6 +20,8 @@ const App: React.FC = () => { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [isScrollAtTop, setIsScrollAtTop] = useState(true); + const [needsOnboarding, setNeedsOnboarding] = useState(false); + const [onboardingChecked, setOnboardingChecked] = useState(false); const { currentPage, navigateToSettings, navigateToFlashcard } = useNavigationStore(); // 오늘의 플래시카드 데이터 로드 @@ -33,6 +37,41 @@ const App: React.FC = () => { return () => unsubscribe(); }, []); + // 온보딩 필요 여부 확인 + useEffect(() => { + const checkOnboarding = async () => { + if (!user) { + setOnboardingChecked(true); + return; + } + + try { + const userDocRef = doc(db, 'users', user.uid); + const userDoc = await getDoc(userDocRef); + + // 문서가 없거나, 온보딩 완료 표시가 없고 리포지토리 설정도 없으면 온보딩 필요 + if (!userDoc.exists()) { + setNeedsOnboarding(true); + } else { + const data = userDoc.data(); + // onboardingCompleted가 true이거나 repositoryFullName이 있으면 온보딩 불필요 + if (data?.onboardingCompleted || data?.repositoryFullName) { + setNeedsOnboarding(false); + } else { + setNeedsOnboarding(true); + } + } + } catch (error) { + console.error('온보딩 확인 실패:', error); + setNeedsOnboarding(false); + } finally { + setOnboardingChecked(true); + } + }; + + checkOnboarding(); + }, [user]); + // 스크롤 위치 감지 useEffect(() => { const handleScroll = () => { @@ -48,18 +87,29 @@ const App: React.FC = () => { return () => window.removeEventListener('scroll', handleScroll); }, []); - // 인증 로딩 중 - if (authLoading) { + // 로딩 중 (인증, 온보딩 확인, 데이터 로딩) + if (authLoading || !onboardingChecked || loading) { return ( -
- 로딩 중... -
+ +
+

📚 플래시카드 준비 중

+

GitHub에서 최근 커밋을 분석하고 있습니다...

+

⏱️ 데이터 양에 따라 시간이 조금 걸릴 수 있습니다

+ +
); } @@ -68,45 +118,15 @@ const App: React.FC = () => { return ; } - // 데이터 로딩 중 - if (loading) { + // 온보딩이 필요한 경우 + if (needsOnboarding) { return ( -
-
-
-

📚 플래시카드 준비 중

-

GitHub에서 최근 커밋을 분석하고 있습니다...

-
- -
+ { + // 온보딩 완료 후 페이지 새로고침으로 깔끔하게 시작 + window.location.reload(); + }} + /> ); } @@ -170,10 +190,36 @@ const App: React.FC = () => { )} - + {currentPage === 'flashcard' && ( + + )} {/* 페이지 컨텐츠 */} diff --git a/app/src/components/Card.css b/app/src/components/Card.css new file mode 100644 index 0000000..233b2b4 --- /dev/null +++ b/app/src/components/Card.css @@ -0,0 +1,23 @@ +.card-container { + display: flex; + flex-direction: column; + height: 100vh; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + text-align: center; + padding: 16px; +} + +.card-content { + background: rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 40px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + max-width: 400px; + width: 100%; +} + diff --git a/app/src/components/Card.tsx b/app/src/components/Card.tsx new file mode 100644 index 0000000..7cb4946 --- /dev/null +++ b/app/src/components/Card.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import './Card.css'; + +interface CardProps { + children: React.ReactNode; + className?: string; +} + +const Card: React.FC = ({ children, className = '' }) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default Card; + diff --git a/app/src/hooks/useTodayFlashcards.ts b/app/src/hooks/useTodayFlashcards.ts index 2098a20..9561eae 100644 --- a/app/src/hooks/useTodayFlashcards.ts +++ b/app/src/hooks/useTodayFlashcards.ts @@ -4,6 +4,7 @@ import { User } from 'firebase/auth'; import { chatCompletions } from '../api/ncloud-api'; import { getCommits, getFilename, getMarkdown, type CommitDetail } from '../api/github-api'; import { getCurrentDate } from '../modules/utils'; +import { useNavigationStore } from '../stores/navigationStore'; const DATES_AGO = [1, 7, 30]; // days ago list @@ -29,6 +30,7 @@ export function useTodayFlashcards(user: User | null) { const { add, getByID } = useIndexedDB("data"); const [loading, setLoading] = useState(true); const [hasData, setHasData] = useState(false); + const flashcardReloadTrigger = useNavigationStore((state) => state.flashcardReloadTrigger); useEffect(() => { // 사용자가 로그인하지 않은 경우 로딩 종료 @@ -40,6 +42,8 @@ export function useTodayFlashcards(user: User | null) { const loadFlashcards = async () => { try { + setLoading(true); + // 오늘 날짜의 데이터가 이미 있는지 확인 const todayData = await getByID(getCurrentDate()); if (todayData) { @@ -68,7 +72,7 @@ export function useTodayFlashcards(user: User | null) { }; loadFlashcards(); - }, [add, getByID, user]); + }, [add, getByID, user, flashcardReloadTrigger]); return { loading, hasData }; } diff --git a/app/src/pages/FlashCardViewer.tsx b/app/src/pages/FlashCardViewer.tsx index 5731e7a..7e7ae01 100644 --- a/app/src/pages/FlashCardViewer.tsx +++ b/app/src/pages/FlashCardViewer.tsx @@ -203,7 +203,7 @@ const FlashCardViewer: React.FC = () => { onClick={flipCard} aria-label="카드 뒤집기" > - {flipped ? '🔙 질문 보기' : '💡 답변 보기'} + {flipped ? '질문 보기' : '카드 뒤집기'} - - + + 데이터 없음 +

+ 📭 플래시카드가 없습니다 +

+

+ 최근 커밋에서 학습할 내용을 찾지 못했습니다 +

+

+ ⚙️ 설정 에서 다른 리포지토리나 브랜치를 시도해보세요 +

+ + +
); }; diff --git a/app/src/pages/Onboarding.css b/app/src/pages/Onboarding.css new file mode 100644 index 0000000..194ead1 --- /dev/null +++ b/app/src/pages/Onboarding.css @@ -0,0 +1,497 @@ +.onboarding-container { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 20px; + overflow-y: auto; +} + +.onboarding-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + animation: gradientShift 10s ease infinite; +} + +@keyframes gradientShift { + 0%, 100% { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + 50% { + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + } +} + +.onboarding-card { + position: relative; + background: white; + border-radius: 24px; + padding: 48px 40px 40px; + max-width: 560px; + width: 100%; + max-height: calc(100vh - 40px); + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.5s ease-out; + margin: auto; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.onboarding-progress { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: rgba(102, 126, 234, 0.1); + border-radius: 24px 24px 0 0; + overflow: hidden; +} + +.onboarding-progress-bar { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + transition: width 0.4s ease; +} + +.onboarding-step { + text-align: center; + animation: fadeIn 0.4s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.onboarding-icon { + font-size: 64px; + margin-bottom: 24px; +} + +.onboarding-character { + display: flex; + justify-content: center; +} + +.character-image { + width: 160px; + height: auto; + max-width: 100%; + object-fit: contain; +} + +.onboarding-icon-success { + animation: successPulse 0.6s ease-out; +} + +@keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes successPulse { + 0% { + transform: scale(0.5); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.onboarding-title { + font-size: 32px; + font-weight: 700; + color: #2d3748; + margin: 0 0 16px; +} + +.onboarding-description { + font-size: 16px; + color: #718096; + line-height: 1.6; + margin: 0 0 32px; +} + +.onboarding-description strong { + color: #667eea; + font-weight: 600; +} + +/* Step 1 - Features */ +.onboarding-features { + margin: 32px 0; + text-align: left; +} + +.onboarding-feature { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: #f7fafc; + border-radius: 12px; + margin-bottom: 12px; + transition: all 0.3s ease; +} + +.onboarding-feature:hover { + background: #edf2f7; + transform: translateX(4px); +} + +.feature-icon { + font-size: 24px; + flex-shrink: 0; +} + +.feature-text { + font-size: 14px; + color: #4a5568; + font-weight: 500; +} + +/* Step 2 - Form */ +.onboarding-form { + margin: 32px 0; + text-align: left; +} + +.form-label { + display: block; + font-size: 14px; + font-weight: 600; + color: #2d3748; + margin-bottom: 8px; +} + +.form-loading { + font-weight: 400; + color: #667eea; + font-size: 12px; +} + +.custom-dropdown { + position: relative; + width: 100%; +} + +.dropdown-button { + width: 100%; + padding: 12px 16px; + background: white; + border: 2px solid #e2e8f0; + border-radius: 12px; + font-size: 14px; + text-align: left; + cursor: pointer; + transition: all 0.2s; + display: flex; + justify-content: space-between; + align-items: center; +} + +.dropdown-button:hover:not(:disabled) { + border-color: #667eea; +} + +.dropdown-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.dropdown-button .placeholder { + color: #a0aec0; +} + +.dropdown-arrow { + font-size: 12px; + color: #718096; + transition: transform 0.2s; +} + +.dropdown-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: white; + border: 2px solid #e2e8f0; + border-radius: 12px; + max-height: 200px; + overflow-y: auto; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + z-index: 100; + animation: dropdownSlide 0.2s ease-out; +} + +@keyframes dropdownSlide { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dropdown-item { + padding: 12px 16px; + cursor: pointer; + transition: background 0.2s; + font-size: 14px; +} + +.dropdown-item:hover:not(.disabled) { + background: #f7fafc; +} + +.dropdown-item.disabled { + color: #a0aec0; + cursor: not-allowed; +} + +.repo-name { + font-weight: 600; + color: #2d3748; + margin-bottom: 4px; +} + +.repo-description { + font-size: 12px; + color: #718096; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Error Message */ +.onboarding-error { + background: #fff5f5; + border: 2px solid #fc8181; + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; + display: flex; + gap: 12px; + align-items: flex-start; + animation: errorSlide 0.3s ease-out; +} + +@keyframes errorSlide { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.error-icon { + font-size: 24px; + flex-shrink: 0; +} + +.error-content { + flex: 1; +} + +.error-message { + color: #c53030; + font-size: 14px; + line-height: 1.5; + margin: 0 0 16px; + font-weight: 500; +} + +.error-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.error-action-button { + padding: 10px 16px; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + flex: 1; + min-width: 150px; +} + +.error-logout-button { + background: #c53030; + color: white; +} + +.error-logout-button:hover { + background: #9b2c2c; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(197, 48, 48, 0.3); +} + +.error-skip-button { + background: white; + color: #718096; + border: 2px solid #e2e8f0; +} + +.error-skip-button:hover { + background: #f7fafc; + border-color: #cbd5e0; + color: #4a5568; +} + +/* Buttons */ +.onboarding-button { + padding: 14px 32px; + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + width: 100%; +} + +.onboarding-button-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + margin-bottom: 12px; +} + +.onboarding-button-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); +} + +.onboarding-button-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.onboarding-button-secondary { + background: white; + color: #718096; + border: 2px solid #e2e8f0; +} + +.onboarding-button-secondary:hover:not(:disabled) { + background: #f7fafc; + border-color: #cbd5e0; + color: #4a5568; +} + +.onboarding-button-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Step 3 - Spinner */ +.onboarding-spinner { + width: 60px; + height: 60px; + border: 4px solid rgba(102, 126, 234, 0.2); + border-top: 4px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 32px auto 0; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Steps Indicator */ +.onboarding-steps-indicator { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 32px; +} + +.step-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #e2e8f0; + transition: all 0.3s; +} + +.step-dot.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + width: 24px; + border-radius: 4px; +} + +/* Responsive */ +@media (max-width: 640px) { + .onboarding-card { + padding: 40px 24px 32px; + margin: 0 16px; + } + + .onboarding-title { + font-size: 28px; + } + + .onboarding-description { + font-size: 14px; + } + + .onboarding-icon { + font-size: 48px; + } + + .character-image { + width: 140px; + } + + .feature-text { + font-size: 13px; + } +} + diff --git a/app/src/pages/Onboarding.tsx b/app/src/pages/Onboarding.tsx new file mode 100644 index 0000000..38c27ca --- /dev/null +++ b/app/src/pages/Onboarding.tsx @@ -0,0 +1,466 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { doc, setDoc } from 'firebase/firestore'; +import { signOut } from 'firebase/auth'; +import { useIndexedDB } from 'react-indexed-db-hook'; +import { auth, db } from '../firebase'; +import { getRepositories, getBranches, Branch } from '../api/github-api'; +import { Repository } from '@til-alarm/shared'; +import './Onboarding.css'; + +interface OnboardingProps { + onComplete: () => void; +} + +interface RepositorySettings { + repositoryFullName: string; + repositoryUrl: string; + branch: string; +} + +const CACHE_KEY = 'github_repositories'; +const getBranchCacheKey = (owner: string, repo: string) => `github_branches_${owner}_${repo}`; + +const Onboarding: React.FC = ({ onComplete }) => { + const [step, setStep] = useState(1); + const [settings, setSettings] = useState({ + repositoryFullName: '', + repositoryUrl: '', + branch: 'main', + }); + const [repositories, setRepositories] = useState([]); + const [branches, setBranches] = useState([]); + const [loadingRepos, setLoadingRepos] = useState(false); + const [loadingBranches, setLoadingBranches] = useState(false); + const [saving, setSaving] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isBranchDropdownOpen, setIsBranchDropdownOpen] = useState(false); + const [error, setError] = useState<{ type: 'repos' | 'branches' | 'save'; message: string } | null>(null); + + const repositoriesDB = useIndexedDB('repositories'); + + // 리포지토리 목록 가져오기 + const fetchRepositories = useCallback(async () => { + try { + setLoadingRepos(true); + setError(null); + + // 캐시 확인 + try { + const cached = await repositoriesDB.getByID(CACHE_KEY); + if (cached) { + setRepositories(cached.data); + setLoadingRepos(false); + return; + } + } catch (cacheError) { + console.error('캐시 읽기 실패:', cacheError); + } + + // API 호출 + const repos = await getRepositories(); + setRepositories(repos); + + // 캐시 저장 + try { + await repositoriesDB.add({ id: CACHE_KEY, data: repos, timestamp: Date.now() }); + } catch (error) { + console.error('캐시 저장 실패:', error); + } + + setLoadingRepos(false); + } catch (error: any) { + console.error('리포지토리 불러오기 실패:', error); + const errorMessage = error?.response?.status === 401 || error?.response?.status === 403 + ? 'GitHub 접근 권한이 없습니다. 다시 로그인해주세요.' + : 'GitHub 리포지토리를 불러오는데 실패했습니다.'; + + setError({ + type: 'repos', + message: errorMessage + }); + setLoadingRepos(false); + } + }, [repositoriesDB]); + + // 브랜치 목록 가져오기 + const fetchBranches = useCallback(async (owner: string, repo: string) => { + try { + setLoadingBranches(true); + setError(null); + + const cacheKey = getBranchCacheKey(owner, repo); + + // 캐시 확인 + try { + const cached = await repositoriesDB.getByID(cacheKey); + if (cached) { + setBranches(cached.data); + setLoadingBranches(false); + return; + } + } catch (cacheError) { + console.error('브랜치 캐시 읽기 실패:', cacheError); + } + + // API 호출 + const branchList = await getBranches(owner, repo); + setBranches(branchList); + + // 캐시 저장 + try { + await repositoriesDB.add({ id: cacheKey, data: branchList, timestamp: Date.now() }); + } catch (error) { + console.error('브랜치 캐시 저장 실패:', error); + } + + setLoadingBranches(false); + } catch (error: any) { + console.error('브랜치 불러오기 실패:', error); + const errorMessage = error?.response?.status === 401 || error?.response?.status === 403 + ? 'GitHub 접근 권한이 없습니다. 다시 로그인해주세요.' + : '브랜치 목록을 불러오는데 실패했습니다.'; + + setError({ + type: 'branches', + message: errorMessage + }); + setLoadingBranches(false); + } + }, [repositoriesDB]); + + // Step 2에 진입하면 리포지토리 목록 로드 + useEffect(() => { + if (step === 2 && repositories.length === 0) { + fetchRepositories(); + } + }, [step, repositories.length, fetchRepositories]); + + // 리포지토리 선택 시 브랜치 로드 + useEffect(() => { + if (settings.repositoryFullName && step === 2) { + const [owner, repo] = settings.repositoryFullName.split('/'); + if (owner && repo) { + fetchBranches(owner, repo); + } + } + }, [settings.repositoryFullName, step, fetchBranches]); + + const handleRepositorySelect = (repo: Repository) => { + setSettings({ + repositoryFullName: repo.full_name, + repositoryUrl: repo.html_url, + branch: 'main', + }); + setIsDropdownOpen(false); + setBranches([]); + }; + + const handleBranchSelect = (branch: Branch) => { + setSettings(prev => ({ ...prev, branch: branch.name })); + setIsBranchDropdownOpen(false); + }; + + const handleSaveSettings = async () => { + if (!auth.currentUser) return; + if (!settings.repositoryFullName || !settings.branch) { + setError({ + type: 'save', + message: '리포지토리와 브랜치를 모두 선택해주세요.' + }); + return; + } + + try { + setSaving(true); + setError(null); + const userDocRef = doc(db, 'users', auth.currentUser.uid); + await setDoc(userDocRef, { + repositoryFullName: settings.repositoryFullName, + repositoryUrl: settings.repositoryUrl, + branch: settings.branch, + onboardingCompleted: true, + onboardingSkipped: false, + updatedAt: new Date(), + }, { merge: true }); + + // Step 3으로 이동 + setStep(3); + + // 2초 후 온보딩 완료 + setTimeout(() => { + onComplete(); + }, 2000); + } catch (error: any) { + console.error('설정 저장 실패:', error); + setError({ + type: 'save', + message: error?.message || '설정 저장에 실패했습니다. 네트워크 연결을 확인해주세요.' + }); + setSaving(false); + } + }; + + const handleNext = () => { + if (step === 1) { + setStep(2); + } else if (step === 2) { + handleSaveSettings(); + } + }; + + const canProceed = () => { + if (step === 1) return true; + if (step === 2) return settings.repositoryFullName && settings.branch; + return false; + }; + + const handleSkipOnboarding = async () => { + if (!window.confirm('온보딩을 건너뛰시겠습니까? 나중에 설정 페이지에서 리포지토리를 설정할 수 있습니다.')) { + return; + } + + try { + if (!auth.currentUser) return; + + // 온보딩을 건너뛰었다는 표시를 Firestore에 저장 + const userDocRef = doc(db, 'users', auth.currentUser.uid); + await setDoc(userDocRef, { + onboardingCompleted: true, + onboardingSkipped: true, + updatedAt: new Date(), + }, { merge: true }); + + onComplete(); + } catch (error) { + console.error('온보딩 스킵 저장 실패:', error); + // 에러가 나도 일단 진행 + onComplete(); + } + }; + + const handleLogout = async () => { + try { + await signOut(auth); + // 로그아웃 후 자동으로 Login 페이지로 이동됨 + } catch (error) { + console.error('로그아웃 실패:', error); + alert('로그아웃에 실패했습니다.'); + } + }; + + return ( +
+
+ +
+ {/* Progress Bar */} +
+
+
+ + {/* Step 1: 환영 */} + {step === 1 && ( +
+
+ RecallBuddy 캐릭터 +
+

환영합니다!

+

+ RecallBuddy가 여러분의 학습을
+ 소중한 장기 기억으로 만들어드립니다 +

+ +
+
+ 🔄 + 1일, 7일, 30일 전 커밋 자동 분석 +
+
+ 💡 + AI가 핵심 내용을 질문으로 변환 +
+
+ 📱 + 매일 아침 푸시 알림으로 학습 +
+
+ + +
+ )} + + {/* Step 2: 리포지토리 선택 */} + {step === 2 && ( +
+
⚙️
+

리포지토리 선택

+

+ 학습하고 싶은 GitHub 리포지토리를 선택해주세요 +

+ + {/* 에러 메시지 */} + {error && ( +
+ ⚠️ +
+

{error.message}

+
+ + +
+
+
+ )} + +
+ {/* 리포지토리 선택 */} +
+ +
+ + + {isDropdownOpen && ( +
+ {repositories.length === 0 ? ( +
+ 리포지토리가 없습니다 +
+ ) : ( + repositories.map((repo) => ( +
handleRepositorySelect(repo)} + > +
{repo.full_name}
+ {repo.description && ( +
{repo.description}
+ )} +
+ )) + )} +
+ )} +
+
+ + {/* 브랜치 선택 */} + {settings.repositoryFullName && ( +
+ +
+ + + {isBranchDropdownOpen && ( +
+ {branches.length === 0 ? ( +
+ 브랜치가 없습니다 +
+ ) : ( + branches.map((branch) => ( +
handleBranchSelect(branch)} + > + {branch.name} +
+ )) + )} +
+ )} +
+
+ )} +
+ + + + +
+ )} + + {/* Step 3: 완료 */} + {step === 3 && ( +
+
+

준비 완료!

+

+ 플래시카드를 생성하고 있습니다...
+ 잠시만 기다려주세요 +

+ +
+
+ )} + + {/* Step Indicator */} +
+
= 1 ? 'active' : ''}`}>
+
= 2 ? 'active' : ''}`}>
+
= 3 ? 'active' : ''}`}>
+
+
+
+ ); +}; + +export default Onboarding; + diff --git a/app/src/pages/Settings.css b/app/src/pages/Settings.css index d794697..ae70b20 100644 --- a/app/src/pages/Settings.css +++ b/app/src/pages/Settings.css @@ -16,6 +16,63 @@ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); } +/* 공지사항 배너 */ +.notice-banner { + display: flex; + align-items: flex-start; + gap: 12px; + background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); + border: 2px solid #ff9800; + border-radius: 12px; + padding: 16px; + margin-bottom: 32px; + animation: notice-fade-in 0.5s ease-out; +} + +@keyframes notice-fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.notice-icon { + font-size: 1.5rem; + flex-shrink: 0; + animation: notice-shake 2s ease-in-out infinite; +} + +@keyframes notice-shake { + 0%, 100% { + transform: rotate(0deg); + } + 10%, 30% { + transform: rotate(-10deg); + } + 20%, 40% { + transform: rotate(10deg); + } + 50% { + transform: rotate(0deg); + } +} + +.notice-content { + flex: 1; +} + +.notice-text { + margin: 0; + color: #e65100; + font-size: 0.9rem; + line-height: 1.6; + font-weight: 500; +} + .settings-header { text-align: center; margin-bottom: 40px; @@ -322,6 +379,7 @@ font-size: 0.95rem; font-weight: 500; margin-top: 16px; + margin-bottom: 16px; animation: message-slide-in 0.3s ease-out; } @@ -350,17 +408,11 @@ box-shadow: 0 2px 8px rgba(252, 129, 129, 0.2); } -.settings-footer { - margin-top: 24px; - padding-top: 24px; - border-top: 1px solid #e2e8f0; -} - .info-text { margin: 0 0 8px 0; font-size: 0.85rem; color: #718096; - text-align: center; + text-align: left; line-height: 1.6; } @@ -404,32 +456,82 @@ 100% { transform: rotate(360deg); } } -/* 위험 구역 - 회원탈퇴 */ -.danger-zone { - margin-top: 40px; - padding: 24px; - border: 2px solid #feb2b2; - border-radius: 12px; - background: #fff5f5; +/* 릴리즈 노트 */ +.release-note-zone { + padding: 0; + border-top: 1px solid #e2e8f0; + text-align: left; + margin-top: 32px; + padding-top: 20px; } -.danger-zone-title { +.release-note-title { margin: 0 0 8px 0; - color: #c53030; - font-size: 1.2rem; - font-weight: 700; + color: #4a5568; + font-size: 1rem; + font-weight: 600; } -.danger-zone-description { +.release-note-description { margin: 0 0 16px 0; - color: #742a2a; + color: #718096; font-size: 0.9rem; - line-height: 1.5; + line-height: 1.6; } -.delete-account-button { - padding: 10px 20px; - background: #fc8181; +.release-note-button { + display: inline-block; + padding: 12px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +.release-note-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); +} + +/* 계정 관리 */ +.account-zone { + padding: 0; + border-top: 1px solid #e2e8f0; + text-align: left; + margin-top: 16px; + padding-top: 20px; +} + +.account-zone-title { + margin: 0 0 8px 0; + color: #4a5568; + font-size: 1rem; + font-weight: 600; +} + +.account-description { + margin: 0 0 16px 0; + color: #718096; + font-size: 0.9rem; + line-height: 1.6; +} + +.account-buttons { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.logout-button { + flex: 1; + padding: 12px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 8px; @@ -437,12 +539,31 @@ font-weight: 600; cursor: pointer; transition: all 0.2s; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +.logout-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); +} + +.delete-account-button { + flex: 1; + padding: 12px 24px; + background: transparent; + color: #a0aec0; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; } .delete-account-button:hover { - background: #f56565; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4); + color: #718096; + border-color: #cbd5e0; + background: #f7fafc; } /* 모달 스타일 */ @@ -495,31 +616,51 @@ .modal-title { margin: 0 0 16px 0; - color: #c53030; + color: #4a5568; font-size: 1.5rem; font-weight: 700; } .modal-description { - margin: 0 0 16px 0; + margin: 0 0 20px 0; color: #4a5568; font-size: 1rem; line-height: 1.6; } -.modal-warning-list { +.modal-info-box { margin: 0 0 24px 0; + padding: 16px; + background: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.info-box-title { + margin: 0 0 12px 0; + color: #4a5568; + font-size: 0.95rem; + font-weight: 600; +} + +.modal-info-list { + margin: 0; padding-left: 20px; - color: #742a2a; - background: #fff5f5; - border-left: 3px solid #fc8181; - padding: 12px 12px 12px 32px; - border-radius: 4px; + color: #718096; } -.modal-warning-list li { +.modal-info-list li { margin: 8px 0; - line-height: 1.5; + line-height: 1.6; + font-size: 0.9rem; +} + +.modal-info-list li.info-reauth { + color: #667eea; + font-weight: 500; + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed #e2e8f0; } .confirm-input { @@ -534,8 +675,8 @@ .confirm-input:focus { outline: none; - border-color: #fc8181; - box-shadow: 0 0 0 3px rgba(252, 129, 129, 0.1); + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } .confirm-input:disabled { @@ -572,14 +713,14 @@ } .modal-button.danger { - background: #fc8181; + background: #a0aec0; color: white; } .modal-button.danger:hover:not(:disabled) { - background: #f56565; + background: #718096; transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4); + box-shadow: 0 4px 12px rgba(113, 128, 150, 0.3); } .modal-button:disabled { @@ -597,6 +738,59 @@ font-size: 1.5rem; } + .notice-banner { + padding: 12px; + margin-bottom: 24px; + } + + .notice-icon { + font-size: 1.2rem; + } + + .notice-text { + font-size: 0.85rem; + } + + .release-note-zone { + padding: 0; + padding-top: 16px; + } + + .release-note-title { + font-size: 0.95rem; + } + + .release-note-description { + font-size: 0.85rem; + } + + .release-note-button { + font-size: 0.9rem; + padding: 10px 20px; + } + + .account-zone { + padding: 0; + padding-top: 16px; + } + + .account-zone-title { + font-size: 0.95rem; + } + + .account-description { + font-size: 0.85rem; + } + + .account-buttons { + flex-direction: column; + } + + .logout-button, + .delete-account-button { + width: 100%; + } + .modal-content { padding: 24px; } @@ -614,3 +808,40 @@ } } +/* 저장 버튼 */ +.save-settings-button { + width: 100%; + padding: 12px 24px; + font-size: 16px; + font-weight: bold; + color: white; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + border-radius: 8px; + cursor: pointer; + margin-top: 20px; + margin-bottom: 20px; + transition: all 0.2s; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +.save-settings-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); +} + +.save-settings-button:disabled { + background: #ccc; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* 설정 페이지 푸터 */ +.settings-footer { + border-top: 1px solid #e0e0e0; + text-align: center; + margin-top: 16px; + padding-top: 16px; +} + diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 78da5a0..cb5f5f8 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,9 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { doc, getDoc, setDoc, deleteDoc } from 'firebase/firestore'; +import { doc, getDoc, setDoc, deleteDoc, collection, onSnapshot } from 'firebase/firestore'; +import { reauthenticateWithPopup } from 'firebase/auth'; import { useIndexedDB } from 'react-indexed-db-hook'; -import { auth, db } from '../firebase'; +import { auth, db, githubProvider } from '../firebase'; import { getRepositories, getBranches, Branch } from '../api/github-api'; import { Repository } from '@til-alarm/shared'; +import TermsLinks from '../widgets/TermsLinks'; import './Settings.css'; interface RepositorySettings { @@ -12,8 +14,14 @@ interface RepositorySettings { branch: string; } +interface Notice { + id: string; + message: string; +} + // 캐시 설정 (컴포넌트 외부로 이동) const CACHE_KEY = 'github_repositories'; +const getBranchCacheKey = (owner: string, repo: string) => `github_branches_${owner}_${repo}`; const Settings: React.FC = () => { const [settings, setSettings] = useState({ @@ -29,32 +37,58 @@ const Settings: React.FC = () => { const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isBranchDropdownOpen, setIsBranchDropdownOpen] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); const [deleting, setDeleting] = useState(false); - const [hasChanges, setHasChanges] = useState(false); + const [notices, setNotices] = useState([]); const dropdownRef = useRef(null); + const branchDropdownRef = useRef(null); // IndexedDB 훅 const repositoriesDB = useIndexedDB('repositories'); const flashcardsDB = useIndexedDB('data'); // 플래시카드 데이터 스토어 - + // 드롭다운 외부 클릭 감지 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsDropdownOpen(false); } + if (branchDropdownRef.current && !branchDropdownRef.current.contains(event.target as Node)) { + setIsBranchDropdownOpen(false); + } }; - if (isDropdownOpen) { + if (isDropdownOpen || isBranchDropdownOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isDropdownOpen]); + }, [isDropdownOpen, isBranchDropdownOpen]); + + // Firestore에서 공지사항 실시간 가져오기 + useEffect(() => { + const unsubscribe = onSnapshot( + collection(db, 'notices'), + (snapshot) => { + const noticesList = snapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data() + } as Notice)); + + console.log('📢 공지사항 업데이트:', noticesList.length, '개'); + setNotices(noticesList); + }, + (error) => { + console.error('❌ 공지사항 가져오기 실패:', error); + } + ); + + return () => unsubscribe(); + }, []); // GitHub 리포지토리 목록 불러오기 (IndexedDB 캐싱) const fetchRepositories = useCallback(async (forceRefresh = false) => { @@ -114,14 +148,58 @@ const Settings: React.FC = () => { } }, []); - // 브랜치 목록 불러오기 - const fetchBranches = useCallback(async (owner: string, repo: string) => { + // 브랜치 목록 불러오기 (IndexedDB 캐싱) + const fetchBranches = useCallback(async (owner: string, repo: string, forceRefresh = false) => { try { setLoadingBranches(true); - console.log(`🌿 브랜치 목록 불러오기: ${owner}/${repo}`); + const cacheKey = getBranchCacheKey(owner, repo); + + // 캐시 확인 (수동 새로고침이 아닌 경우) + if (!forceRefresh) { + try { + const cached = await repositoriesDB.getByID(cacheKey); + if (cached) { + const now = Date.now(); + const cacheAge = now - cached.timestamp; + console.log(`✅ 캐시된 브랜치 목록 사용 (IndexedDB) - ${Math.floor(cacheAge / 1000 / 60)}분 전 캐시`); + setBranches(cached.data); + setLoadingBranches(false); + return; + } else { + console.log('📭 브랜치 캐시 없음 - API 호출'); + } + } catch (cacheError) { + console.error('❌ 브랜치 캐시 읽기 실패:', cacheError); + } + } else { + console.log('🔄 수동 새로고침 - API 호출'); + } + + // API 호출 + console.log(`🌿 API에서 브랜치 목록 불러오기: ${owner}/${repo}`); const branchList = await getBranches(owner, repo); setBranches(branchList); console.log(`✅ ${branchList.length}개의 브랜치 발견`); + + // IndexedDB에 캐시 저장 + try { + const cacheData = { + id: cacheKey, + data: branchList, + timestamp: Date.now(), + }; + + // 기존 캐시 확인 + const existing = await repositoriesDB.getByID(cacheKey); + if (existing) { + await repositoriesDB.update(cacheData); + } else { + await repositoriesDB.add(cacheData); + } + console.log('💾 브랜치 목록 캐시 저장 완료 (IndexedDB)'); + } catch (saveError) { + console.error('❌ 브랜치 캐시 저장 실패:', saveError); + } } catch (error) { console.error('❌ 브랜치 불러오기 실패:', error); setMessage({ type: 'error', text: '브랜치 목록을 불러오는데 실패했습니다.' }); @@ -129,7 +207,7 @@ const Settings: React.FC = () => { } finally { setLoadingBranches(false); } - }, []); + }, [repositoriesDB]); // 설정 및 리포지토리 목록 불러오기 useEffect(() => { @@ -201,8 +279,6 @@ const Settings: React.FC = () => { // 브랜치 목록 불러오기 const [owner, repoName] = repo.full_name.split('/'); await fetchBranches(owner, repoName); - - setHasChanges(true); }; // 설정 저장 @@ -228,20 +304,14 @@ const Settings: React.FC = () => { setMessage(null); try { - // 기존 리포지토리 확인 + // 기존 데이터 확인 const userDoc = await getDoc(doc(db, 'users', user.uid)); const existingData = userDoc.exists() ? userDoc.data() : {}; - const previousRepo = existingData.repositoryFullName; - const previousBranch = existingData.branch; - - // 리포지토리 또는 브랜치가 변경되었는지 확인 - const isRepoOrBranchChanged = - (previousRepo && previousRepo !== settings.repositoryFullName) || - (previousBranch && previousBranch !== settings.branch); // full_name에서 username과 repository 분리 const [githubUsername, repositoryName] = settings.repositoryFullName.split('/'); + // Firestore에 설정 저장 await setDoc(doc(db, 'users', user.uid), { ...existingData, repositoryFullName: settings.repositoryFullName, @@ -252,40 +322,26 @@ const Settings: React.FC = () => { updatedAt: new Date().toISOString(), }); - // 리포지토리 또는 브랜치가 변경된 경우 플래시카드 데이터 삭제 및 페이지 새로고침 - if (isRepoOrBranchChanged) { - try { - await flashcardsDB.clear(); - console.log('🗑️ 설정 변경으로 인해 플래시카드 데이터를 삭제했습니다.'); - setMessage({ type: 'success', text: '✅ 설정이 저장되었습니다. 페이지를 새로고침합니다...' }); - - // 1초 후 페이지 새로고침 - setTimeout(() => { - window.location.reload(); - }, 1000); - } catch (clearError) { - console.error('❌ 플래시카드 데이터 삭제 실패:', clearError); - setMessage({ type: 'success', text: '✅ 설정이 저장되었습니다.' }); - setHasChanges(false); - - // 3초 후 메시지 자동 제거 - setTimeout(() => { - setMessage(null); - }, 3000); - } - } else { - setMessage({ type: 'success', text: '✅ 설정이 성공적으로 저장되었습니다!' }); - setHasChanges(false); + // 저장 후 항상 플래시카드 데이터 삭제하고 새로 생성 + try { + // 모든 캐시된 플래시카드 데이터 삭제 + await flashcardsDB.clear(); + console.log('🗑️ 플래시카드 데이터를 삭제했습니다.'); - // 3초 후 메시지 자동 제거 + setMessage({ type: 'success', text: '✅ 설정이 저장되었습니다. 새로운 데이터를 불러옵니다...' }); + + // 페이지 새로고침으로 플래시카드 새로 생성 setTimeout(() => { - setMessage(null); - }, 3000); + window.location.reload(); + }, 500); + } catch (clearError) { + console.error('❌ 플래시카드 데이터 삭제 실패:', clearError); + setMessage({ type: 'error', text: '데이터 삭제에 실패했습니다. 다시 시도해주세요.' }); + setSaving(false); } } catch (error) { console.error('설정 저장 실패:', error); setMessage({ type: 'error', text: '설정 저장에 실패했습니다.' }); - } finally { setSaving(false); } }; @@ -293,6 +349,16 @@ const Settings: React.FC = () => { // 선택된 리포지토리 찾기 const selectedRepo = repositories.find(repo => repo.full_name === settings.repositoryFullName); + // 로그아웃 핸들러 + const handleLogout = async () => { + try { + await auth.signOut(); + } catch (error) { + console.error('로그아웃 실패:', error); + setMessage({ type: 'error', text: '로그아웃에 실패했습니다.' }); + } + }; + // 회원탈퇴 핸들러 const handleDeleteAccount = async () => { const user = auth.currentUser; @@ -311,26 +377,96 @@ const Settings: React.FC = () => { setDeleting(true); setMessage(null); - // 1. Firestore 데이터 삭제 + // 1. 탈퇴 기록 생성 (재가입 방지용) + await setDoc(doc(db, 'deletedUsers', user.uid), { + deletedAt: new Date().toISOString(), + email: user.email, + githubUsername: user.displayName, + }); + + // 2. Firestore 사용자 데이터 삭제 await deleteDoc(doc(db, 'users', user.uid)); - // 2. Firebase Auth 계정 삭제 + // 3. IndexedDB 모든 데이터 삭제 + try { + await flashcardsDB.clear(); + await repositoriesDB.clear(); + console.log('🗑️ IndexedDB 데이터 삭제 완료'); + } catch (dbError) { + console.error('❌ IndexedDB 삭제 실패:', dbError); + } + + // 4. Firebase Auth 계정 삭제 await user.delete(); - console.log('회원탈퇴 완료'); + console.log('✅ 회원탈퇴 완료'); } catch (error: any) { console.error('회원탈퇴 실패:', error); - // 재인증이 필요한 경우 - if (error.code === 'auth/requires-recent-login') { - setMessage({ - type: 'error', - text: '보안을 위해 다시 로그인한 후 탈퇴를 진행해주세요.' - }); + // 재인증이 필요한 경우 (다양한 오류 코드 처리) + const needsReauth = + error.code === 'auth/requires-recent-login' || + error.message?.includes('CREDENTIAL_TOO_OLD') || + error.message?.includes('LOGIN_AGAIN'); + + if (needsReauth) { + try { + // 자동으로 재인증 시도 + console.log('🔄 재인증이 필요합니다. GitHub 로그인 팝업을 엽니다...'); + setMessage({ + type: 'error', + text: '보안을 위해 재인증이 필요합니다. 팝업에서 GitHub 로그인을 진행해주세요.' + }); + + await reauthenticateWithPopup(user, githubProvider); + console.log('✅ 재인증 완료'); + + // 재인증 후 다시 계정 삭제 시도 + setMessage({ type: 'error', text: '재인증되었습니다. 다시 탈퇴를 시도합니다...' }); + + // 1. 탈퇴 기록 생성 + await setDoc(doc(db, 'deletedUsers', user.uid), { + deletedAt: new Date().toISOString(), + email: user.email, + githubUsername: user.displayName, + }); + + // 2. Firestore 사용자 데이터 삭제 + await deleteDoc(doc(db, 'users', user.uid)); + + // 3. IndexedDB 모든 데이터 삭제 + try { + await flashcardsDB.clear(); + await repositoriesDB.clear(); + console.log('🗑️ IndexedDB 데이터 삭제 완료'); + } catch (dbError) { + console.error('❌ IndexedDB 삭제 실패:', dbError); + } + + // 4. Firebase Auth 계정 삭제 + await user.delete(); + + console.log('✅ 회원탈퇴 완료'); + } catch (reauthError: any) { + console.error('재인증 실패:', reauthError); + + if (reauthError.code === 'auth/popup-closed-by-user') { + setMessage({ + type: 'error', + text: '재인증이 취소되었습니다. 탈퇴를 계속하려면 다시 시도해주세요.' + }); + } else { + setMessage({ + type: 'error', + text: '재인증에 실패했습니다. 잠시 후 다시 시도해주세요.' + }); + } + setDeleting(false); + } } else { setMessage({ type: 'error', text: '회원탈퇴에 실패했습니다.' }); + setDeleting(false); } - setDeleting(false); } }; @@ -348,10 +484,19 @@ const Settings: React.FC = () => { return (
-
-

⚙️ 리포지토리 설정

-

학습 내용을 가져올 GitHub 리포지토리를 선택하세요

-
+ {/* 공지사항 */} + {notices.length > 0 && ( +
+
📢
+
+ {notices.map((notice, index) => ( +

+ {notice.message} +

+ ))} +
+
+ )}
@@ -446,12 +591,29 @@ const Settings: React.FC = () => {
- +
+ + {settings.repositoryFullName && ( + + )} +

커밋을 가져올 브랜치를 선택하세요 + {branches.length > 0 && ` (총 ${branches.length}개의 브랜치)`}

{loadingBranches ? ( @@ -460,30 +622,49 @@ const Settings: React.FC = () => { 브랜치 목록을 불러오는 중...
) : branches.length > 0 ? ( - +
+ + + {isBranchDropdownOpen && !saving && ( +
+ {branches.map((branch) => ( +
{ + setSettings({ ...settings, branch: branch.name }); + setIsBranchDropdownOpen(false); + }} + > +
+ {branch.name} + {branch.protected && 🔒} +
+
+ ))} +
+ )} +
) : (

리포지토리를 선택하면 브랜치 목록이 표시됩니다. @@ -504,64 +685,81 @@ const Settings: React.FC = () => { type="button" className="save-settings-button" onClick={handleSaveSettings} - disabled={saving || !hasChanges} - style={{ - width: '100%', - padding: '12px 24px', - fontSize: '16px', - fontWeight: 'bold', - color: 'white', - backgroundColor: hasChanges ? '#4CAF50' : '#ccc', - border: 'none', - borderRadius: '8px', - cursor: hasChanges && !saving ? 'pointer' : 'not-allowed', - marginTop: '20px', - transition: 'background-color 0.3s', - }} + disabled={saving || !settings.repositoryFullName || !settings.branch} > - {saving ? '저장 중...' : hasChanges ? '설정 저장' : '저장됨'} + {saving ? '저장 중...' : '🚀 설정 저장'} )}

- -
-

- ℹ️ GitHub OAuth로 로그인하여 접근 가능한 모든 리포지토리가 표시됩니다. -

-

- 🔒 = Private 리포지토리, 🌐 = Public 리포지토리 +

+ ℹ️ GitHub OAuth로 로그인하여 접근 가능한 모든 리포지토리가 표시됩니다. +

+

+ 🔒 = Private 리포지토리, 🌐 = Public 리포지토리 +

+ + {/* 릴리즈 노트 */} +
+

📝 릴리즈 노트

+

+ 새로운 기능과 개선사항을 확인하세요

+ + 📋 릴리즈 노트 보기 +
- {/* 위험 구역 - 회원탈퇴 */} -
-

⚠️ 위험 구역

-

- 회원탈퇴 시 모든 데이터가 영구적으로 삭제되며 복구할 수 없습니다. + {/* 계정 관리 */} +

+

👤 계정 관리

+

+ 계정 로그아웃 또는 서비스 탈퇴를 진행할 수 있습니다.

- +
+ + +
+
+ + {/* 이용약관 링크 */} +
+
- {/* 회원탈퇴 확인 다이얼로그 */} + {/* 서비스 탈퇴 확인 다이얼로그 */} {showDeleteDialog && (
!deleting && setShowDeleteDialog(false)}>
e.stopPropagation()}> -

⚠️ 회원탈퇴

+

👋 서비스 탈퇴

- 정말로 탈퇴하시겠습니까? 이 작업은 되돌릴 수 없습니다. + 정말 탈퇴하시겠어요? 걱정하지 마세요, 언제든 다시 돌아올 수 있습니다.

-
    -
  • 모든 설정 데이터가 삭제됩니다
  • -
  • 저장된 GitHub 토큰이 삭제됩니다
  • -
  • 계정이 완전히 삭제됩니다
  • -
+
+

✨ 탈퇴 시 안내사항

+
    +
  • 저장된 모든 데이터가 삭제됩니다
  • +
  • 탈퇴 후 24시간 이내에는 재가입할 수 없습니다
  • +
  • 💡 보안을 위해 GitHub 재인증 팝업이 표시될 수 있습니다
  • +
+