diff --git a/App.tsx b/App.tsx index 0d43b03..16562bb 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { GitHubUser, Repository, Issue, AppRoute } from './types'; +import { GitHubUser, Repository, Issue, AppRoute, Account } from './types'; import { TokenGate } from './views/TokenGate'; import { Dashboard } from './views/Dashboard'; import { RepoDetail } from './views/RepoDetail'; @@ -9,18 +9,26 @@ import { validateToken } from './services/githubService'; import { ThemeProvider } from './contexts/ThemeContext'; const App: React.FC = () => { - const [token, setToken] = useState(localStorage.getItem('gh_token')); - const [user, setUser] = useState( - localStorage.getItem('gh_user') ? JSON.parse(localStorage.getItem('gh_user')!) : null - ); + const [accounts, setAccounts] = useState(() => { + const stored = localStorage.getItem('gh_accounts'); + return stored ? JSON.parse(stored) : []; + }); + const [currentAccountId, setCurrentAccountId] = useState(() => { + return localStorage.getItem('gh_current_account'); + }); const [checkingRedirect, setCheckingRedirect] = useState(true); const [currentRoute, setCurrentRoute] = useState( - token && user ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT + currentAccountId && accounts.length > 0 ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT ); const [selectedRepo, setSelectedRepo] = useState(null); const [selectedIssue, setSelectedIssue] = useState(null); + // Helper to get current account + const currentAccount = accounts.find(acc => acc.id === currentAccountId) || null; + const currentToken = currentAccount?.token || null; + const currentUser = currentAccount?.user || null; + // Handle redirect result from Firebase OAuth (for popup-blocked fallback) useEffect(() => { const checkRedirectResult = async () => { @@ -42,26 +50,51 @@ const App: React.FC = () => { }, []); const handleLogin = (newToken: string, newUser: GitHubUser) => { - setToken(newToken); - setUser(newUser); - localStorage.setItem('gh_token', newToken); - localStorage.setItem('gh_user', JSON.stringify(newUser)); + const accountId = newUser.login; + const newAccount: Account = { + id: accountId, + token: newToken, + user: newUser, + }; + + const updatedAccounts = accounts.filter(acc => acc.id !== accountId).concat(newAccount); + + setAccounts(updatedAccounts); + setCurrentAccountId(accountId); + localStorage.setItem('gh_accounts', JSON.stringify(updatedAccounts)); + localStorage.setItem('gh_current_account', accountId); setCurrentRoute(AppRoute.REPO_LIST); }; - const handleLogout = async () => { - // Sign out from Firebase + const handleLogout = async (accountId?: string) => { + const accountToLogout = accountId || currentAccountId; + if (!accountToLogout) return; + + // Sign out from Firebase (this signs out the current Firebase user) try { await signOutFromFirebase(); } catch (err) { console.error('Firebase sign out error:', err); } - - setToken(null); - setUser(null); - localStorage.removeItem('gh_token'); - localStorage.removeItem('gh_user'); - setCurrentRoute(AppRoute.TOKEN_INPUT); + + // Remove the account from accounts + const updatedAccounts = accounts.filter(acc => acc.id !== accountToLogout); + setAccounts(updatedAccounts); + localStorage.setItem('gh_accounts', JSON.stringify(updatedAccounts)); + + // If this was the current account, switch to another or go to login + if (accountToLogout === currentAccountId) { + if (updatedAccounts.length > 0) { + const nextAccount = updatedAccounts[0]; + setCurrentAccountId(nextAccount.id); + localStorage.setItem('gh_current_account', nextAccount.id); + } else { + setCurrentAccountId(null); + localStorage.removeItem('gh_current_account'); + setCurrentRoute(AppRoute.TOKEN_INPUT); + } + } + setSelectedRepo(null); }; @@ -86,6 +119,15 @@ const App: React.FC = () => { setCurrentRoute(AppRoute.REPO_DETAIL); }; + const switchAccount = (accountId: string) => { + setCurrentAccountId(accountId); + localStorage.setItem('gh_current_account', accountId); + // Navigate back to dashboard when switching accounts + setSelectedRepo(null); + setSelectedIssue(null); + setCurrentRoute(AppRoute.REPO_LIST); + }; + // Render Logic if (checkingRedirect) { return ( @@ -95,17 +137,18 @@ const App: React.FC = () => { ); } - if (currentRoute === AppRoute.TOKEN_INPUT || !token || !user) { + if (currentRoute === AppRoute.TOKEN_INPUT || !currentToken || !currentUser) { return ; } if (currentRoute === AppRoute.ISSUE_DETAIL && selectedRepo && selectedIssue) { return ( ); } @@ -113,20 +156,26 @@ const App: React.FC = () => { if (currentRoute === AppRoute.REPO_DETAIL && selectedRepo) { return ( ); } return ( setCurrentRoute(AppRoute.TOKEN_INPUT)} + accountId={currentAccountId || ''} /> ); }; diff --git a/services/cacheService.ts b/services/cacheService.ts index d862ba9..21f346e 100644 --- a/services/cacheService.ts +++ b/services/cacheService.ts @@ -61,13 +61,13 @@ export function clearCache(key?: string): void { // Cache keys helper export const CacheKeys = { - repos: () => 'repos', - repoIssues: (owner: string, repo: string) => `issues_${owner}_${repo}`, - issueComments: (owner: string, repo: string, issueNumber: number) => `comments_${owner}_${repo}_${issueNumber}`, - workflowRuns: (owner: string, repo: string) => `workflows_${owner}_${repo}`, - prDetails: (owner: string, repo: string, prNumber: number) => `pr_${owner}_${repo}_${prNumber}`, - issueExpandedData: (owner: string, repo: string, issueNumber: number) => `expanded_${owner}_${repo}_${issueNumber}`, - workflowFiles: () => 'workflow_files', + repos: (accountId?: string) => accountId ? `repos_${accountId}` : 'repos', + repoIssues: (owner: string, repo: string, accountId?: string) => accountId ? `issues_${accountId}_${owner}_${repo}` : `issues_${owner}_${repo}`, + issueComments: (owner: string, repo: string, issueNumber: number, accountId?: string) => accountId ? `comments_${accountId}_${owner}_${repo}_${issueNumber}` : `comments_${owner}_${repo}_${issueNumber}`, + workflowRuns: (owner: string, repo: string, accountId?: string) => accountId ? `workflows_${accountId}_${owner}_${repo}` : `workflows_${owner}_${repo}`, + prDetails: (owner: string, repo: string, prNumber: number, accountId?: string) => accountId ? `pr_${accountId}_${owner}_${repo}_${prNumber}` : `pr_${owner}_${repo}_${prNumber}`, + issueExpandedData: (owner: string, repo: string, issueNumber: number, accountId?: string) => accountId ? `expanded_${accountId}_${owner}_${repo}_${issueNumber}` : `expanded_${owner}_${repo}_${issueNumber}`, + workflowFiles: (accountId?: string) => accountId ? `workflow_files_${accountId}` : 'workflow_files', }; // Type for cached expanded issue data (all data needed for expanded view) diff --git a/types.ts b/types.ts index e080f70..3d142c2 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,12 @@ export interface GitHubUser { name: string; } +export interface Account { + id: string; // Use GitHub login as unique identifier + token: string; + user: GitHubUser; +} + export interface Repository { id: number; name: string; diff --git a/views/Dashboard.tsx b/views/Dashboard.tsx index 253013c..0e410d3 100644 --- a/views/Dashboard.tsx +++ b/views/Dashboard.tsx @@ -1,30 +1,35 @@ import React, { useEffect, useState, useRef } from 'react'; -import { Repository, GitHubUser, RepoDraft, Issue } from '../types'; +import { Repository, GitHubUser, RepoDraft, Issue, Account } from '../types'; import { fetchRepositories, createRepository, deleteRepository } from '../services/githubService'; import { RepoCard } from '../components/RepoCard'; import { Button } from '../components/Button'; import { ToastContainer, useToast } from '../components/Toast'; import { ThemeToggle } from '../components/ThemeToggle'; -import { LogOut, RefreshCw, Plus, X, Lock, Globe, AlertTriangle } from 'lucide-react'; +import { LogOut, RefreshCw, Plus, X, Lock, Globe, AlertTriangle, ChevronDown, UserPlus } from 'lucide-react'; import { getCached, setCache, CacheKeys } from '../services/cacheService'; interface DashboardProps { token: string; user: GitHubUser; + accounts: Account[]; + currentAccountId: string | null; onRepoSelect: (repo: Repository) => void; - onLogout: () => void | Promise; + onLogout: (accountId?: string) => void | Promise; + onSwitchAccount: (accountId: string) => void; + onAddAccount: () => void; + accountId: string; } -export const Dashboard: React.FC = ({ token, user, onRepoSelect, onLogout }) => { +export const Dashboard: React.FC = ({ token, user, accounts, currentAccountId, onRepoSelect, onLogout, onSwitchAccount, onAddAccount, accountId }) => { const { toasts, dismissToast, showError } = useToast(); // Initialize from cache for instant display const [repos, setRepos] = useState(() => { - return getCached(CacheKeys.repos()) || []; + return getCached(CacheKeys.repos(accountId)) || []; }); const [loading, setLoading] = useState(() => { // Only show loading if no cached data - return !getCached(CacheKeys.repos()); + return !getCached(CacheKeys.repos(accountId)); }); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(''); @@ -32,9 +37,11 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, const saved = localStorage.getItem('pinnedRepos'); return saved ? new Set(JSON.parse(saved)) : new Set(); }); + const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false); + const dropdownRef = useRef(null); // Initialize issues from cache for instant display const [repoIssues, setRepoIssues] = useState>(() => { - const cachedRepos = getCached(CacheKeys.repos()); + const cachedRepos = getCached(CacheKeys.repos(accountId)); if (!cachedRepos) return {}; const issuesMap: Record = {}; @@ -91,7 +98,7 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, const data = await fetchRepositories(token); setRepos(data); // Cache the repos for instant display on next visit - setCache(CacheKeys.repos(), data); + setCache(CacheKeys.repos(accountId), data); // Load issues for first 4 repos - reuse cache when available const reposToShow = data.slice(0, 4); @@ -122,10 +129,24 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, }, [token, repos.length]); useEffect(() => { - // Always fetch fresh data on mount, but show cached immediately + // Always fetch fresh data when accountId changes, but show cached immediately loadRepos(false); isInitialMount.current = false; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [accountId]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsAccountDropdownOpen(false); + } + }; + + if (isAccountDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isAccountDropdownOpen]); const handleCreateRepo = async (e: React.FormEvent) => { e.preventDefault(); @@ -204,21 +225,72 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect,
- {/* Header */} -
-
-
- {user.login} - {user.login} -
-
- - -
-
-
+ {/* Header */} +
+
+ {/* Account Switcher */} +
+ + + {isAccountDropdownOpen && ( +
+
+
+ Switch Account +
+ {accounts.map((account) => ( + + ))} + +
+ + +
+
+ )} +
+ +
+ + +
+
+
diff --git a/views/IssueDetail.tsx b/views/IssueDetail.tsx index 7048292..7a3d221 100644 --- a/views/IssueDetail.tsx +++ b/views/IssueDetail.tsx @@ -12,14 +12,15 @@ interface IssueDetailProps { repo: Repository; issue: Issue; onBack: () => void; + accountId: string; } -export const IssueDetail: React.FC = ({ token, repo, issue, onBack }) => { +export const IssueDetail: React.FC = ({ token, repo, issue, onBack, accountId }) => { const { toasts, dismissToast, showSuccess, showError, showInfo } = useToast(); // Cache keys - const issuesCacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name); - const expandedCacheKey = CacheKeys.issueExpandedData(repo.owner.login, repo.name, issue.number); + const issuesCacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name, accountId); + const expandedCacheKey = CacheKeys.issueExpandedData(repo.owner.login, repo.name, issue.number, accountId); // Get cached data for instant display const cachedData = getCached(expandedCacheKey); diff --git a/views/RepoDetail.tsx b/views/RepoDetail.tsx index 110eb7a..4a1fe04 100644 --- a/views/RepoDetail.tsx +++ b/views/RepoDetail.tsx @@ -11,11 +11,12 @@ interface RepoDetailProps { repo: Repository; onBack: () => void; onIssueSelect: (issue: Issue) => void; + accountId: string; } -export const RepoDetail: React.FC = ({ token, repo, onBack, onIssueSelect }) => { +export const RepoDetail: React.FC = ({ token, repo, onBack, onIssueSelect, accountId }) => { const { toasts, dismissToast, showError } = useToast(); - const cacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name); + const cacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name, accountId); // Initialize from cache for instant display const [issues, setIssues] = useState(() => { @@ -36,7 +37,7 @@ export const RepoDetail: React.FC = ({ token, repo, onBack, onI // Workflow Files State const [workflowFiles, setWorkflowFiles] = useState(() => { - return getCached(CacheKeys.workflowFiles()) || []; + return getCached(CacheKeys.workflowFiles(accountId)) || []; }); const [loadingWorkflows, setLoadingWorkflows] = useState(false); const [workflowsExpanded, setWorkflowsExpanded] = useState(false); @@ -99,7 +100,7 @@ export const RepoDetail: React.FC = ({ token, repo, onBack, onI }; const loadWorkflowFiles = React.useCallback(async () => { - const cachedWorkflows = getCached(CacheKeys.workflowFiles()); + const cachedWorkflows = getCached(CacheKeys.workflowFiles(accountId)); if (cachedWorkflows && cachedWorkflows.length > 0) { setWorkflowFiles(cachedWorkflows); return; @@ -108,11 +109,11 @@ export const RepoDetail: React.FC = ({ token, repo, onBack, onI setLoadingWorkflows(true); try { // Fetch repos first if needed - const repos = getCached(CacheKeys.repos()) || []; + const repos = getCached(CacheKeys.repos(accountId)) || []; if (repos.length > 0) { const workflows = await fetchAllWorkflowFiles(token, repos); setWorkflowFiles(workflows); - setCache(CacheKeys.workflowFiles(), workflows); + setCache(CacheKeys.workflowFiles(accountId), workflows); } } catch (err) { console.error('Failed to load workflow files:', err);