diff --git a/.gitignore b/.gitignore index 715819f..c74e381 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules # production app/dist functions/dist +packages/shared/dist # misc .DS_Store diff --git a/app/package.json b/app/package.json index bc721de..2f26151 100644 --- a/app/package.json +++ b/app/package.json @@ -8,6 +8,7 @@ "build": "tsc && vite build" }, "dependencies": { + "@til-alarm/shared": "workspace:*", "axios": "^1.12.2", "firebase": "^10.4.0", "react": "^18.2.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index 4c50458..9c62205 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import FlashCardViewer from './pages/FlashCardViewer'; import Login from './pages/Login'; import Settings from './pages/Settings'; import { getGithubData } from './services/github-service'; +import UserDropdown from './widgets/UserDropdown'; initDB(DBConfig); const dates = [1, 7, 30]; // days ago list @@ -22,6 +23,7 @@ const App: React.FC = () => { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [currentPage, setCurrentPage] = useState('flashcard'); + const [isScrollAtTop, setIsScrollAtTop] = useState(true); // 인증 상태 감지 useEffect(() => { @@ -33,6 +35,21 @@ const App: React.FC = () => { return () => unsubscribe(); }, []); + // 스크롤 위치 감지 + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const isAtTop = scrollTop <= 10; // 10px 이하면 맨 위로 간주 + setIsScrollAtTop(isAtTop); + }; + + // 초기 스크롤 위치 체크 + handleScroll(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + // 사용자가 로그인한 경우에만 데이터 로드 useEffect(() => { if (!user) { @@ -114,55 +131,60 @@ const App: React.FC = () => { top: 0, left: 0, right: 0, - background: 'rgba(255, 255, 255, 0.95)', - backdropFilter: 'blur(10px)', - boxShadow: '0 2px 10px rgba(0,0,0,0.1)', + background: 'transparent', zIndex: 1000, padding: '12px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', + opacity: isScrollAtTop ? 1 : 0, + transform: isScrollAtTop ? 'translateY(0)' : 'translateY(-20px)', + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: isScrollAtTop ? 'auto' : 'none', }}> -
- - +
+ {currentPage === 'settings' && ( + + )}
-
- 👤 {user.displayName || user.email} -
+ setCurrentPage('settings')} + /> {/* 페이지 컨텐츠 */} -
+
{currentPage === 'flashcard' && } {currentPage === 'settings' && }
diff --git a/app/src/DBConfig.ts b/app/src/DBConfig.ts index 45cedc7..b9e1ccf 100644 --- a/app/src/DBConfig.ts +++ b/app/src/DBConfig.ts @@ -1,6 +1,6 @@ export const DBConfig = { name: 'MyDB', - version: 1, + version: 2, objectStoresMeta: [ { store: 'data', @@ -9,6 +9,15 @@ export const DBConfig = { { name: 'date', keypath: 'date', options: { unique: false }}, { name: 'data', keypath: 'data', options: { unique: false }} ] + }, + { + store: 'repositories', + storeConfig: { keyPath: 'id', autoIncrement: false }, + storeSchema: [ + { name: 'id', keypath: 'id', options: { unique: true }}, + { name: 'data', keypath: 'data', options: { unique: false }}, + { name: 'timestamp', keypath: 'timestamp', options: { unique: false }} + ] } ] }; diff --git a/app/src/api/github-api.ts b/app/src/api/github-api.ts index 60ec260..a1538bc 100644 --- a/app/src/api/github-api.ts +++ b/app/src/api/github-api.ts @@ -1,4 +1,5 @@ import { apiClient } from '../modules/axios'; +import type { Repository } from '@til-alarm/shared'; interface Commit { sha: string; @@ -47,3 +48,8 @@ export async function getMarkdown(filename: string): Promise { const data: MarkdownResponse = response.data; return data.content; } + +export async function getRepositories(): Promise { + const response = await apiClient.get('/getRepositories'); + return response.data; +} diff --git a/app/src/index.css b/app/src/index.css index 42124f9..4cc15ae 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -377,6 +377,10 @@ button.primary, width: 1px; } +.card-player { + padding-top: 20px; +} + .card-player .slick-list { z-index: 1; } diff --git a/app/src/pages/Login.tsx b/app/src/pages/Login.tsx index 2c69583..36a740c 100644 --- a/app/src/pages/Login.tsx +++ b/app/src/pages/Login.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { signInWithPopup, signOut, onAuthStateChanged, User, GithubAuthProvider } from 'firebase/auth'; -import { doc, setDoc, deleteDoc } from 'firebase/firestore'; +import { doc, setDoc, updateDoc, getDoc } from 'firebase/firestore'; import { auth, githubProvider, db } from '../firebase'; import './Login.css'; @@ -30,10 +30,22 @@ const Login: React.FC = () => { // Google의 at-rest encryption으로 자동 암호화됨 const credential = GithubAuthProvider.credentialFromResult(result); if (credential && credential.accessToken && result.user) { - await setDoc(doc(db, 'users', result.user.uid), { - githubToken: credential.accessToken, - updatedAt: new Date().toISOString(), - }); + const userDocRef = doc(db, 'users', result.user.uid); + const userDoc = await getDoc(userDocRef); + + if (userDoc.exists()) { + // 기존 문서가 있으면 토큰만 업데이트 (설정 유지) + await updateDoc(userDocRef, { + githubToken: credential.accessToken, + updatedAt: new Date().toISOString(), + }); + } else { + // 신규 사용자는 새 문서 생성 + await setDoc(userDocRef, { + githubToken: credential.accessToken, + updatedAt: new Date().toISOString(), + }); + } console.log('로그인 성공 및 GitHub 토큰 저장 완료'); } @@ -50,10 +62,19 @@ const Login: React.FC = () => { const handleLogout = async () => { try { const currentUser = auth.currentUser; - await signOut(auth); if (currentUser) { - await deleteDoc(doc(db, 'users', currentUser.uid)); + // 문서가 존재하는 경우에만 githubToken 필드만 업데이트 + const userDocRef = doc(db, 'users', currentUser.uid); + const userDoc = await getDoc(userDocRef); + + if (userDoc.exists()) { + await updateDoc(userDocRef, { + githubToken: null, + updatedAt: new Date().toISOString(), + }); + } } + await signOut(auth); console.log('로그아웃 성공'); } catch (error) { console.error('로그아웃 실패:', error); diff --git a/app/src/pages/Settings.css b/app/src/pages/Settings.css index f8edac2..d794697 100644 --- a/app/src/pages/Settings.css +++ b/app/src/pages/Settings.css @@ -2,9 +2,9 @@ min-height: 100vh; display: flex; justify-content: center; - align-items: center; + align-items: flex-start; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - padding: 20px; + padding: 80px 20px 20px; } .settings-card { @@ -45,36 +45,237 @@ gap: 8px; } +.form-label-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + .form-group label { font-weight: 600; color: #333; font-size: 0.95rem; } +.refresh-button { + padding: 6px 12px; + background: transparent; + color: #667eea; + border: 1px solid #667eea; + border-radius: 6px; + font-size: 1.1rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + min-width: 40px; + height: 32px; +} + +.refresh-button:hover:not(:disabled) { + background: #667eea; + color: white; + transform: rotate(180deg); +} + +.refresh-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .required { color: #e53e3e; margin-left: 4px; } -.form-input { +.form-input, +.form-select { padding: 12px 16px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 1rem; transition: all 0.2s; font-family: 'Consolas', 'Monaco', monospace; + width: 100%; + background-color: white; +} + +.form-select { + cursor: pointer; +} + +.form-select:disabled { + cursor: not-allowed; + opacity: 0.6; + background-color: #f7fafc; +} + +.form-input:focus, +.form-select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* Custom Select Styles */ +.custom-select-container { + position: relative; + width: 100%; +} + +.custom-select-trigger { + width: 100%; + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + background-color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + transition: all 0.2s; + text-align: left; + font-size: 1rem; +} + +.custom-select-trigger:hover:not(:disabled) { + border-color: #cbd5e0; } -.form-input:focus { +.custom-select-trigger:focus, +.custom-select-trigger.open { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } +.custom-select-trigger:disabled { + cursor: not-allowed; + opacity: 0.6; + background-color: #f7fafc; +} + +.custom-select-trigger.saving { + cursor: wait; + opacity: 0.8; +} + +.selected-repo { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.repo-name { + font-family: 'Consolas', 'Monaco', monospace; + font-weight: 500; + color: #2d3748; +} + +.repo-badge { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + background-color: #e2e8f0; + color: #4a5568; + white-space: nowrap; +} + +.placeholder { + color: #a0aec0; +} + +.dropdown-arrow { + color: #718096; + font-size: 0.75rem; +} + +.custom-select-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + max-height: 300px; + overflow-y: auto; + background: white; + border: 2px solid #667eea; + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); + z-index: 1000; + animation: dropdown-fade-in 0.2s ease-out; +} + +@keyframes dropdown-fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.custom-select-option { + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.15s; + border-bottom: 1px solid #f7fafc; +} + +.custom-select-option:last-child { + border-bottom: none; +} + +.custom-select-option:hover { + background-color: #f7fafc; +} + +.custom-select-option.selected { + background-color: #edf2f7; +} + +.option-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; +} + +.option-name { + font-family: 'Consolas', 'Monaco', monospace; + font-weight: 600; + color: #2d3748; + font-size: 0.95rem; +} + +.option-badge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 3px; + background-color: #e2e8f0; + color: #4a5568; + white-space: nowrap; +} + +.option-description { + font-size: 0.85rem; + color: #718096; + line-height: 1.4; + margin-top: 4px; + padding-left: 2px; +} + .form-hint { - margin: 0; + margin: 0 0 12px 0; font-size: 0.85rem; color: #718096; + font-weight: 500; } .form-preview { @@ -104,46 +305,49 @@ word-break: break-all; } +.repo-link { + color: #667eea; + text-decoration: none; + transition: color 0.2s; +} + +.repo-link:hover { + color: #764ba2; + text-decoration: underline; +} + .message { - padding: 12px 16px; + padding: 14px 18px; border-radius: 8px; - font-size: 0.9rem; + font-size: 0.95rem; font-weight: 500; + margin-top: 16px; + animation: message-slide-in 0.3s ease-out; +} + +@keyframes message-slide-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .message.success { background: #c6f6d5; color: #22543d; border: 1px solid #9ae6b4; + box-shadow: 0 2px 8px rgba(72, 187, 120, 0.2); } .message.error { background: #fed7d7; color: #742a2a; border: 1px solid #fc8181; -} - -.save-button { - padding: 14px 24px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - border-radius: 8px; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - transition: all 0.3s; - margin-top: 8px; -} - -.save-button:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); -} - -.save-button:disabled { - opacity: 0.6; - cursor: not-allowed; + box-shadow: 0 2px 8px rgba(252, 129, 129, 0.2); } .settings-footer { @@ -153,13 +357,29 @@ } .info-text { - margin: 0; + margin: 0 0 8px 0; font-size: 0.85rem; color: #718096; text-align: center; line-height: 1.6; } +.info-text:last-child { + margin-bottom: 0; +} + +.loading-repos { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: #f7fafc; + border: 2px solid #e2e8f0; + border-radius: 8px; + color: #4a5568; + font-size: 0.95rem; +} + .loading-spinner { border: 4px solid #f3f3f3; border-top: 4px solid #667eea; @@ -170,11 +390,203 @@ margin: 0 auto 16px; } +.loading-spinner-small { + border: 3px solid #f3f3f3; + border-top: 3px solid #667eea; + border-radius: 50%; + width: 20px; + height: 20px; + animation: spin 1s linear infinite; +} + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } +/* 위험 구역 - 회원탈퇴 */ +.danger-zone { + margin-top: 40px; + padding: 24px; + border: 2px solid #feb2b2; + border-radius: 12px; + background: #fff5f5; +} + +.danger-zone-title { + margin: 0 0 8px 0; + color: #c53030; + font-size: 1.2rem; + font-weight: 700; +} + +.danger-zone-description { + margin: 0 0 16px 0; + color: #742a2a; + font-size: 0.9rem; + line-height: 1.5; +} + +.delete-account-button { + padding: 10px 20px; + background: #fc8181; + color: white; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + 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); +} + +/* 모달 스타일 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; + animation: modal-fade-in 0.2s ease-out; + padding: 20px; +} + +@keyframes modal-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal-content { + background: white; + border-radius: 16px; + padding: 32px; + max-width: 500px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + animation: modal-slide-up 0.3s ease-out; + max-height: 90vh; + overflow-y: auto; +} + +@keyframes modal-slide-up { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-title { + margin: 0 0 16px 0; + color: #c53030; + font-size: 1.5rem; + font-weight: 700; +} + +.modal-description { + margin: 0 0 16px 0; + color: #4a5568; + font-size: 1rem; + line-height: 1.6; +} + +.modal-warning-list { + margin: 0 0 24px 0; + padding-left: 20px; + color: #742a2a; + background: #fff5f5; + border-left: 3px solid #fc8181; + padding: 12px 12px 12px 32px; + border-radius: 4px; +} + +.modal-warning-list li { + margin: 8px 0; + line-height: 1.5; +} + +.confirm-input { + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.2s; + width: 100%; + font-family: inherit; +} + +.confirm-input:focus { + outline: none; + border-color: #fc8181; + box-shadow: 0 0 0 3px rgba(252, 129, 129, 0.1); +} + +.confirm-input:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #f7fafc; +} + +.modal-actions { + display: flex; + gap: 12px; + margin-top: 24px; + justify-content: flex-end; +} + +.modal-button { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + min-width: 100px; +} + +.modal-button.cancel { + background: #e2e8f0; + color: #4a5568; +} + +.modal-button.cancel:hover:not(:disabled) { + background: #cbd5e0; +} + +.modal-button.danger { + background: #fc8181; + color: white; +} + +.modal-button.danger:hover:not(:disabled) { + background: #f56565; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4); +} + +.modal-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* 반응형 */ @media (max-width: 768px) { .settings-card { @@ -184,5 +596,21 @@ .settings-header h1 { font-size: 1.5rem; } + + .modal-content { + padding: 24px; + } + + .modal-title { + font-size: 1.25rem; + } + + .modal-actions { + flex-direction: column; + } + + .modal-button { + width: 100%; + } } diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index f8d2d94..5950098 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,24 +1,124 @@ -import React, { useState, useEffect } from 'react'; -import { doc, getDoc, setDoc } from 'firebase/firestore'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { doc, getDoc, setDoc, deleteDoc } from 'firebase/firestore'; +import { useIndexedDB } from 'react-indexed-db-hook'; import { auth, db } from '../firebase'; +import { getRepositories } from '../api/github-api'; +import { Repository } from '@til-alarm/shared'; import './Settings.css'; interface RepositorySettings { - githubUsername: string; - repositoryName: string; + repositoryFullName: string; + repositoryUrl: string; } +// 캐시 설정 (컴포넌트 외부로 이동) +const CACHE_KEY = 'github_repositories'; +const CACHE_DURATION = 30 * 60 * 1000; // 30분 + const Settings: React.FC = () => { const [settings, setSettings] = useState({ - githubUsername: '', - repositoryName: 'TIL', + repositoryFullName: '', + repositoryUrl: '', }); + const [repositories, setRepositories] = useState([]); const [loading, setLoading] = useState(true); + const [loadingRepos, setLoadingRepos] = useState(false); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [deleting, setDeleting] = useState(false); + const dropdownRef = useRef(null); + + // IndexedDB 훅 + const repositoriesDB = useIndexedDB('repositories'); - // 설정 불러오기 + // 드롭다운 외부 클릭 감지 useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; + + if (isDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isDropdownOpen]); + + // GitHub 리포지토리 목록 불러오기 (IndexedDB 캐싱) + const fetchRepositories = useCallback(async (forceRefresh = false) => { + try { + setLoadingRepos(true); + + // 캐시 확인 (강제 새로고침이 아닌 경우) + if (!forceRefresh) { + try { + const cached = await repositoriesDB.getByID(CACHE_KEY); + if (cached) { + const now = Date.now(); + const cacheAge = now - cached.timestamp; + + // 캐시가 유효한 경우 (30분 이내) + if (cacheAge < CACHE_DURATION) { + console.log(`✅ 캐시된 리포지토리 목록 사용 (IndexedDB) - ${Math.floor(cacheAge / 1000 / 60)}분 전 캐시`); + setRepositories(cached.data); + setLoadingRepos(false); + return; + } else { + console.log(`⏰ 캐시 만료됨 (${Math.floor(cacheAge / 1000 / 60)}분 경과) - API 호출`); + } + } else { + console.log('📭 캐시 없음 - API 호출'); + } + } catch (cacheError) { + console.error('❌ 캐시 읽기 실패:', cacheError); + } + } else { + console.log('🔄 강제 새로고침 - API 호출'); + } + + // API 호출 + console.log('🌐 API에서 리포지토리 목록 불러오기...'); + const repos = await getRepositories(); + setRepositories(repos); + + // IndexedDB에 캐시 저장 + try { + const cacheData = { + id: CACHE_KEY, + data: repos, + timestamp: Date.now(), + }; + + // 기존 캐시 확인 + const existing = await repositoriesDB.getByID(CACHE_KEY); + 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: '리포지토리 목록을 불러오는데 실패했습니다.' }); + } finally { + setLoadingRepos(false); + } + }, []); + + // 설정 및 리포지토리 목록 불러오기 + useEffect(() => { + let mounted = true; + const loadSettings = async () => { const user = auth.currentUser; if (!user) { @@ -30,24 +130,41 @@ const Settings: React.FC = () => { const userDoc = await getDoc(doc(db, 'users', user.uid)); if (userDoc.exists()) { const data = userDoc.data(); - setSettings({ - githubUsername: data.githubUsername || '', - repositoryName: data.repositoryName || 'TIL', - }); + + // 저장된 설정 불러오기 + if (mounted) { + setSettings({ + repositoryFullName: data.repositoryFullName || '', + repositoryUrl: data.repositoryUrl || '', + }); + } + } + + // 리포지토리 목록 불러오기 (캐시 우선) + if (mounted) { + await fetchRepositories(); } } catch (error) { console.error('설정 불러오기 실패:', error); + if (mounted) { + setMessage({ type: 'error', text: '설정을 불러오는데 실패했습니다.' }); + } } finally { - setLoading(false); + if (mounted) { + setLoading(false); + } } }; loadSettings(); - }, []); - // 설정 저장 - const handleSave = async (e: React.FormEvent) => { - e.preventDefault(); + return () => { + mounted = false; + }; + }, []); // 마운트 시 한 번만 실행 + + // 리포지토리 선택 및 즉시 저장 + const handleRepositorySelect = async (repo: Repository) => { const user = auth.currentUser; if (!user) { @@ -55,38 +172,98 @@ const Settings: React.FC = () => { return; } - if (!settings.githubUsername.trim() || !settings.repositoryName.trim()) { - setMessage({ type: 'error', text: '모든 필드를 입력해주세요.' }); - return; - } + setIsDropdownOpen(false); + setSaving(true); + setMessage(null); try { - setSaving(true); - setMessage(null); + // 설정 업데이트 + setSettings({ + repositoryFullName: repo.full_name, + repositoryUrl: repo.html_url, + }); // 기존 데이터 유지하면서 업데이트 const userDoc = await getDoc(doc(db, 'users', user.uid)); const existingData = userDoc.exists() ? userDoc.data() : {}; + // full_name에서 username과 repository 분리 + const [githubUsername, repositoryName] = repo.full_name.split('/'); + await setDoc(doc(db, 'users', user.uid), { ...existingData, - githubUsername: settings.githubUsername.trim(), - repositoryName: settings.repositoryName.trim(), + repositoryFullName: repo.full_name, + repositoryUrl: repo.html_url, + githubUsername, + repositoryName, updatedAt: new Date().toISOString(), }); - setMessage({ type: 'success', text: '설정이 저장되었습니다!' }); + setMessage({ type: 'success', text: '✅ 리포지토리가 성공적으로 변경되었습니다!' }); + + // 3초 후 메시지 자동 제거 + setTimeout(() => { + setMessage(null); + }, 3000); } catch (error) { console.error('설정 저장 실패:', error); setMessage({ type: 'error', text: '설정 저장에 실패했습니다.' }); + // 실패 시 이전 상태로 복원 + const userDoc = await getDoc(doc(db, 'users', user.uid)); + if (userDoc.exists()) { + const data = userDoc.data(); + setSettings({ + repositoryFullName: data.repositoryFullName || '', + repositoryUrl: data.repositoryUrl || '', + }); + } } finally { setSaving(false); } }; - // 입력 변경 핸들러 - const handleChange = (field: keyof RepositorySettings, value: string) => { - setSettings(prev => ({ ...prev, [field]: value })); + // 선택된 리포지토리 찾기 + const selectedRepo = repositories.find(repo => repo.full_name === settings.repositoryFullName); + + // 회원탈퇴 핸들러 + const handleDeleteAccount = async () => { + const user = auth.currentUser; + + if (!user) { + setMessage({ type: 'error', text: '로그인이 필요합니다.' }); + return; + } + + if (deleteConfirmText !== '회원탈퇴') { + setMessage({ type: 'error', text: '"회원탈퇴"를 정확히 입력해주세요.' }); + return; + } + + try { + setDeleting(true); + setMessage(null); + + // 1. Firestore 데이터 삭제 + await deleteDoc(doc(db, 'users', user.uid)); + + // 2. Firebase Auth 계정 삭제 + await user.delete(); + + console.log('회원탈퇴 완료'); + } catch (error: any) { + console.error('회원탈퇴 실패:', error); + + // 재인증이 필요한 경우 + if (error.code === 'auth/requires-recent-login') { + setMessage({ + type: 'error', + text: '보안을 위해 다시 로그인한 후 탈퇴를 진행해주세요.' + }); + } else { + setMessage({ type: 'error', text: '회원탈퇴에 실패했습니다.' }); + } + setDeleting(false); + } }; if (loading) { @@ -105,79 +282,193 @@ const Settings: React.FC = () => {

⚙️ 리포지토리 설정

-

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

+

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

-
+
- - handleChange('githubUsername', e.target.value)} - placeholder="예: hssuh" - className="form-input" - required - /> +
+ + +
+

- GitHub 계정 사용자명 (https://github.com/사용자명) + {repositories.length > 0 + ? `총 ${repositories.length}개의 리포지토리를 찾았습니다` + : '접근 가능한 리포지토리가 없습니다'}

-
+ + {loadingRepos ? ( +
+
+ 리포지토리 목록을 불러오는 중... +
+ ) : ( +
+ -
- - handleChange('repositoryName', e.target.value)} - placeholder="예: TIL" - className="form-input" - required - /> -

- 학습 내용이 저장된 리포지토리 이름 -

+ {isDropdownOpen && !saving && ( +
+ {repositories.map((repo) => ( +
handleRepositorySelect(repo)} + > +
+ {repo.full_name} + {repo.private ? '🔒' : '🌐'} +
+ {repo.description && ( +
{repo.description}
+ )} +
+ ))} +
+ )} +
+ )}
-
-

📂 리포지토리 경로:

- - {settings.githubUsername && settings.repositoryName - ? `https://github.com/${settings.githubUsername}/${settings.repositoryName}` - : '설정을 입력해주세요'} - -
+ {settings.repositoryFullName && ( +
+

📂 선택된 리포지토리:

+ + + {settings.repositoryUrl} + + +
+ )} {message && (
{message.text}
)} - - - +

- ℹ️ 리포지토리는 public이거나, - 로그인한 계정이 접근 권한이 있어야 합니다. + ℹ️ GitHub OAuth로 로그인하여 접근 가능한 모든 리포지토리가 표시됩니다. +

+

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

+ + {/* 위험 구역 - 회원탈퇴 */} +
+

⚠️ 위험 구역

+

+ 회원탈퇴 시 모든 데이터가 영구적으로 삭제되며 복구할 수 없습니다. +

+ +
+ + {/* 회원탈퇴 확인 다이얼로그 */} + {showDeleteDialog && ( +
!deleting && setShowDeleteDialog(false)}> +
e.stopPropagation()}> +

⚠️ 회원탈퇴

+

+ 정말로 탈퇴하시겠습니까? 이 작업은 되돌릴 수 없습니다. +

+
    +
  • 모든 설정 데이터가 삭제됩니다
  • +
  • 저장된 GitHub 토큰이 삭제됩니다
  • +
  • 계정이 완전히 삭제됩니다
  • +
+ +
+ + setDeleteConfirmText(e.target.value)} + placeholder="회원탈퇴" + disabled={deleting} + className="confirm-input" + /> +
+ + {message && message.type === 'error' && ( +
+ {message.text} +
+ )} + +
+ + +
+
+
+ )}
); }; diff --git a/app/src/widgets/UserDropdown.tsx b/app/src/widgets/UserDropdown.tsx new file mode 100644 index 0000000..d025953 --- /dev/null +++ b/app/src/widgets/UserDropdown.tsx @@ -0,0 +1,169 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { User } from 'firebase/auth'; +import { auth } from '../firebase'; + +interface UserDropdownProps { + user: User; + onNavigateToSettings: () => void; +} + +const UserDropdown: React.FC = ({ user, onNavigateToSettings }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // 외부 클릭 감지 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleLogout = async () => { + try { + await auth.signOut(); + } catch (error) { + console.error('로그아웃 실패:', error); + } + }; + + const handleSettingsClick = () => { + setIsOpen(false); + onNavigateToSettings(); + }; + + return ( +
+ + + {isOpen && ( +
+ + +
+ + +
+ )} +
+ ); +}; + +export default UserDropdown; + diff --git a/functions/package.json b/functions/package.json index 1f9e659..9a34fa0 100644 --- a/functions/package.json +++ b/functions/package.json @@ -13,6 +13,7 @@ "deploy:hypercloax": "pnpm build && firebase deploy --only functions:hypercloaxApi,functions:chatCompletions,functions:registerDeviceToken,functions:removeDeviceToken,functions:registerSchedule" }, "dependencies": { + "@til-alarm/shared": "workspace:*", "firebase-admin": "^12.0.0", "firebase-functions": "^4.8.0" }, diff --git a/functions/src/github.ts b/functions/src/github.ts index 9cfcafd..766d3d1 100644 --- a/functions/src/github.ts +++ b/functions/src/github.ts @@ -1,6 +1,7 @@ import { onRequest } from 'firebase-functions/v2/https'; import { getAuth } from 'firebase-admin/auth'; import { getFirestore } from 'firebase-admin/firestore'; +import { Repository } from '@til-alarm/shared'; /** * Firebase ID Token 검증 및 사용자 정보 조회 @@ -186,3 +187,71 @@ export const getMarkdown = onRequest( } } ); + +/** + * 사용자의 GitHub 리포지토리 목록 가져오기 + * Settings 페이지에서 리포지토리 선택을 위해 사용 + */ +export const getRepositories = onRequest( + { cors: true }, + async (req, res) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Firebase ID token not provided. Please authenticate.' }); + return; + } + + const idToken = authHeader.split('Bearer ')[1]; + + // Firebase ID Token 검증 + const decodedToken = await getAuth().verifyIdToken(idToken); + const userId = decodedToken.uid; + + // Firestore에서 GitHub 토큰 조회 + const userDoc = await getFirestore().collection('users').doc(userId).get(); + + if (!userDoc.exists) { + res.status(404).json({ error: 'User not found. Please login again.' }); + return; + } + + const userData = userDoc.data(); + const githubToken = userData?.githubToken; + + if (!githubToken) { + res.status(400).json({ error: 'GitHub token not found. Please login with GitHub again.' }); + return; + } + + // GitHub API로 리포지토리 목록 가져오기 + const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100', { + headers: { + 'Authorization': `Bearer ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error(`GitHub API error: ${response.status}`, errorBody); + res.status(response.status).json({ + error: 'Failed to fetch repositories from GitHub', + details: errorBody + }); + return; + } + + const repositories: Repository[] = await response.json(); + res.json(repositories); + } catch (error) { + console.error('Error fetching repositories:', error); + res.status(500).json({ + error: 'Failed to fetch repositories', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + } +); \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..698df42 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,15 @@ +{ + "name": "@til-alarm/shared", + "version": "1.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..efa4565 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './types'; + diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..bdaf31e --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,19 @@ +/** + * GitHub Repository + * + * id: 리포지토리 고유 아이디 + * name: 리포지토리 이름 + * full_name: 리포지토리 전체 이름 + * description: 리포지토리 설명 + * html_url: 리포지토리 홈페이지 주소 + * private: 리포지토리 공개 여부 + */ +export interface Repository { + id: number; + name: string; + full_name: string; + description: string | null; + html_url: string; + private: boolean; +} + diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..df84093 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dc3439..cf76b1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: app: dependencies: + '@til-alarm/shared': + specifier: workspace:* + version: link:../packages/shared axios: specifier: ^1.12.2 version: 1.12.2 @@ -72,6 +75,9 @@ importers: functions: dependencies: + '@til-alarm/shared': + specifier: workspace:* + version: link:../packages/shared firebase-admin: specifier: ^12.0.0 version: 12.7.0 @@ -89,6 +95,12 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/shared: + devDependencies: + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages: '@apideck/better-ajv-errors@0.3.6': @@ -5270,7 +5282,7 @@ snapshots: '@types/resolve@1.17.1': dependencies: - '@types/node': 24.6.2 + '@types/node': 20.19.19 '@types/send@0.17.5': dependencies: @@ -6497,7 +6509,7 @@ snapshots: jest-worker@26.6.2: dependencies: - '@types/node': 24.6.2 + '@types/node': 20.19.19 merge-stream: 2.0.0 supports-color: 7.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5dbb9e7..c1cf961 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'app' - 'functions' + - 'packages/*'