From b66038815e75fe3ff575b42ecb06bade7320fb07 Mon Sep 17 00:00:00 2001 From: chucoding Date: Wed, 8 Oct 2025 14:26:40 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat=20:=20[TSK-37]=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=84=A0=ED=83=9D=20Selectbox=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + app/package.json | 1 + app/src/api/github-api.ts | 6 + app/src/pages/Settings.css | 203 +++++++++++++++++++++++++++++++++- app/src/pages/Settings.tsx | 191 +++++++++++++++++++++++--------- functions/package.json | 1 + functions/src/github.ts | 69 ++++++++++++ packages/shared/package.json | 15 +++ packages/shared/src/index.ts | 2 + packages/shared/src/types.ts | 19 ++++ packages/shared/tsconfig.json | 18 +++ pnpm-lock.yaml | 16 ++- pnpm-workspace.yaml | 1 + 13 files changed, 485 insertions(+), 58 deletions(-) create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json 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/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/pages/Settings.css b/app/src/pages/Settings.css index f8edac2..a5c42d4 100644 --- a/app/src/pages/Settings.css +++ b/app/src/pages/Settings.css @@ -56,21 +56,182 @@ 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; } -.form-input:focus { +.custom-select-trigger:hover:not(:disabled) { + border-color: #cbd5e0; +} + +.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; +} + +.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; font-size: 0.85rem; @@ -104,6 +265,17 @@ 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; border-radius: 8px; @@ -153,13 +325,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,6 +358,15 @@ 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); } diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index f8d2d94..b87ee45 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,23 +1,60 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { doc, getDoc, setDoc } from 'firebase/firestore'; 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 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 dropdownRef = useRef(null); - // 설정 불러오기 + // 드롭다운 외부 클릭 감지 + 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 리포지토리 목록 불러오기 (Firebase Functions를 통해) + const fetchRepositories = useCallback(async () => { + try { + setLoadingRepos(true); + const repos = await getRepositories(); + setRepositories(repos); + } catch (error) { + console.error('리포지토리 불러오기 실패:', error); + setMessage({ type: 'error', text: '리포지토리 목록을 불러오는데 실패했습니다.' }); + } finally { + setLoadingRepos(false); + } + }, []); + + // 설정 및 리포지토리 목록 불러오기 useEffect(() => { const loadSettings = async () => { const user = auth.currentUser; @@ -30,20 +67,26 @@ 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', + repositoryFullName: data.repositoryFullName || '', + repositoryUrl: data.repositoryUrl || '', }); } + + // Firebase Functions를 통해 리포지토리 목록 불러오기 + await fetchRepositories(); } catch (error) { console.error('설정 불러오기 실패:', error); + setMessage({ type: 'error', text: '설정을 불러오는데 실패했습니다.' }); } finally { setLoading(false); } }; loadSettings(); - }, []); + }, [fetchRepositories]); // 설정 저장 const handleSave = async (e: React.FormEvent) => { @@ -55,8 +98,8 @@ const Settings: React.FC = () => { return; } - if (!settings.githubUsername.trim() || !settings.repositoryName.trim()) { - setMessage({ type: 'error', text: '모든 필드를 입력해주세요.' }); + if (!settings.repositoryFullName) { + setMessage({ type: 'error', text: '리포지토리를 선택해주세요.' }); return; } @@ -68,10 +111,15 @@ const Settings: React.FC = () => { const userDoc = await getDoc(doc(db, 'users', user.uid)); const existingData = userDoc.exists() ? userDoc.data() : {}; + // full_name에서 username과 repository 분리 + const [githubUsername, repositoryName] = settings.repositoryFullName.split('/'); + await setDoc(doc(db, 'users', user.uid), { ...existingData, - githubUsername: settings.githubUsername.trim(), - repositoryName: settings.repositoryName.trim(), + repositoryFullName: settings.repositoryFullName, + repositoryUrl: settings.repositoryUrl, + githubUsername, + repositoryName, updatedAt: new Date().toISOString(), }); @@ -84,11 +132,18 @@ const Settings: React.FC = () => { } }; - // 입력 변경 핸들러 - const handleChange = (field: keyof RepositorySettings, value: string) => { - setSettings(prev => ({ ...prev, [field]: value })); + // 리포지토리 선택 핸들러 + const handleRepositorySelect = (repo: Repository) => { + setSettings({ + repositoryFullName: repo.full_name, + repositoryUrl: repo.html_url, + }); + setIsDropdownOpen(false); }; + // 선택된 리포지토리 찾기 + const selectedRepo = repositories.find(repo => repo.full_name === settings.repositoryFullName); + if (loading) { return (
@@ -105,56 +160,84 @@ const Settings: React.FC = () => {

⚙️ 리포지토리 설정

-

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

+

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

-