Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 67 additions & 19 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
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';
import { IssueDetail } from './views/IssueDetail';
import { signOutFromFirebase, handleRedirectResult } from './services/firebaseService';
import { validateToken } from './services/githubService';
import { ThemeProvider } from './contexts/ThemeContext';
import {
getAllAccounts,
addAccount,
removeAccount,
getActiveAccount,
setActiveAccount,
migrateLegacyAccount,
clearAllAccounts,
} from './services/accountService';
import { clearCache } from './services/cacheService';

const App: React.FC = () => {
const [token, setToken] = useState<string | null>(localStorage.getItem('gh_token'));
const [user, setUser] = useState<GitHubUser | null>(
localStorage.getItem('gh_user') ? JSON.parse(localStorage.getItem('gh_user')!) : null
);
// Migrate legacy single account on first load
useEffect(() => {
migrateLegacyAccount();
}, []);

const [accounts, setAccounts] = useState<Account[]>(() => getAllAccounts());
const [activeAccount, setActiveAccountState] = useState<Account | null>(() => getActiveAccount());
const [checkingRedirect, setCheckingRedirect] = useState(true);

const [currentRoute, setCurrentRoute] = useState<AppRoute>(
token && user ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT
activeAccount ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT
);
const [selectedRepo, setSelectedRepo] = useState<Repository | null>(null);
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
Expand All @@ -42,13 +55,41 @@ 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 account = addAccount(newToken, newUser);
setActiveAccount(account.id);
setAccounts(getAllAccounts());
setActiveAccountState(account);
setCurrentRoute(AppRoute.REPO_LIST);
};

const handleSwitchAccount = (accountId: string) => {
setActiveAccount(accountId);
const account = getActiveAccount();
setActiveAccountState(account);
setCurrentRoute(AppRoute.REPO_LIST);
// Reset navigation state when switching accounts
setSelectedRepo(null);
setSelectedIssue(null);
};

const handleRemoveAccount = (accountId: string) => {
// Clear all cached data for this account
clearCache(undefined, accountId);

removeAccount(accountId);
const updatedAccounts = getAllAccounts();
setAccounts(updatedAccounts);

// If we removed the active account, switch to another or logout
if (activeAccount?.id === accountId) {
if (updatedAccounts.length > 0) {
handleSwitchAccount(updatedAccounts[0].id);
} else {
handleLogout();
}
}
};

const handleLogout = async () => {
// Sign out from Firebase
try {
Expand All @@ -57,10 +98,12 @@ const App: React.FC = () => {
console.error('Firebase sign out error:', err);
}

setToken(null);
setUser(null);
localStorage.removeItem('gh_token');
localStorage.removeItem('gh_user');
// Clear all accounts and cached data
clearAllAccounts();
clearCache();

setAccounts([]);
setActiveAccountState(null);
setCurrentRoute(AppRoute.TOKEN_INPUT);
setSelectedRepo(null);
};
Expand Down Expand Up @@ -95,14 +138,14 @@ const App: React.FC = () => {
);
}

if (currentRoute === AppRoute.TOKEN_INPUT || !token || !user) {
if (currentRoute === AppRoute.TOKEN_INPUT || !activeAccount) {
return <TokenGate onSuccess={handleLogin} />;
}

if (currentRoute === AppRoute.ISSUE_DETAIL && selectedRepo && selectedIssue) {
return (
<IssueDetail
token={token}
token={activeAccount.token}
repo={selectedRepo}
issue={selectedIssue}
onBack={navigateBackToRepo}
Expand All @@ -113,7 +156,7 @@ const App: React.FC = () => {
if (currentRoute === AppRoute.REPO_DETAIL && selectedRepo) {
return (
<RepoDetail
token={token}
token={activeAccount.token}
repo={selectedRepo}
onBack={navigateBack}
onIssueSelect={navigateToIssue}
Expand All @@ -123,10 +166,15 @@ const App: React.FC = () => {

return (
<Dashboard
token={token}
user={user}
token={activeAccount.token}
user={activeAccount.user}
accounts={accounts}
activeAccount={activeAccount}
onRepoSelect={navigateToRepo}
onLogout={handleLogout}
onSwitchAccount={handleSwitchAccount}
onAddAccount={() => setCurrentRoute(AppRoute.TOKEN_INPUT)}
onRemoveAccount={handleRemoveAccount}
/>
);
};
Expand Down
133 changes: 133 additions & 0 deletions components/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React, { useState, useRef, useEffect } from 'react';
import { Account } from '../types';
import { ChevronDown, UserPlus, LogOut, Check } from 'lucide-react';

interface AccountSwitcherProps {
accounts: Account[];
activeAccount: Account;
onSwitchAccount: (accountId: string) => void;
onAddAccount: () => void;
onRemoveAccount: (accountId: string) => void;
}

export const AccountSwitcher: React.FC<AccountSwitcherProps> = ({
accounts,
activeAccount,
onSwitchAccount,
onAddAccount,
onRemoveAccount,
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

const handleSwitchAccount = (accountId: string) => {
onSwitchAccount(accountId);
setIsOpen(false);
};

const handleRemoveAccount = (e: React.MouseEvent, accountId: string) => {
e.stopPropagation();
onRemoveAccount(accountId);
// Don't close the dropdown if there are still accounts
if (accounts.length <= 1) {
setIsOpen(false);
}
};

return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors border border-slate-200 dark:border-slate-600"
>
<img
src={activeAccount.user.avatar_url}
alt={activeAccount.user.login}
className="w-6 h-6 rounded-full"
/>
<span className="font-medium text-slate-900 dark:text-slate-100 text-sm">
{activeAccount.user.login}
</span>
<ChevronDown
size={16}
className={`text-slate-500 dark:text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>

{isOpen && (
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-slate-200 dark:border-slate-700 py-2 z-50">
{/* Account List */}
<div className="px-2 pb-2 border-b border-slate-200 dark:border-slate-700">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 px-2 py-1 mb-1">
ACCOUNTS
</div>
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer group"
>
<div
onClick={() => handleSwitchAccount(account.id)}
className="flex items-center gap-2 flex-1"
>
<img
src={account.user.avatar_url}
alt={account.user.login}
className="w-8 h-8 rounded-full"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{account.user.name || account.user.login}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
@{account.user.login}
</div>
</div>
{account.id === activeAccount.id && (
<Check size={16} className="text-emerald-600 dark:text-emerald-400" />
)}
</div>
{accounts.length > 1 && (
<button
onClick={(e) => handleRemoveAccount(e, account.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-opacity focus:opacity-100"
title="Remove account"
aria-label={`Remove account ${account.user.login}`}
>
<LogOut size={14} className="text-red-600 dark:text-red-400" />
</button>
)}
</div>
))}
</div>

{/* Add Account */}
<div className="px-2 pt-2">
<button
onClick={() => {
onAddAccount();
setIsOpen(false);
}}
className="flex items-center gap-2 w-full px-2 py-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300"
>
<UserPlus size={16} />
<span className="text-sm font-medium">Add Account</span>
</button>
</div>
</div>
)}
</div>
);
};
Loading