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
109 changes: 90 additions & 19 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,30 @@ import { IssueDetail } from './views/IssueDetail';
import { signOutFromFirebase, handleRedirectResult } from './services/firebaseService';
import { validateToken } from './services/githubService';
import { ThemeProvider } from './contexts/ThemeContext';
import {
getActiveAccount,
getAccounts,
addAccount,
removeAccount,
setActiveAccount,
migrateOldAccountData,
Account,
clearAllAccounts
} from './services/accountService';

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 old single-account data on first load
useEffect(() => {
migrateOldAccountData();
}, []);

const [currentAccount, setCurrentAccount] = useState<Account | null>(() => getActiveAccount());
const [accounts, setAccounts] = useState<Account[]>(() => getAccounts());
const [checkingRedirect, setCheckingRedirect] = useState(true);
const [addingAccount, setAddingAccount] = useState(false);

const [currentRoute, setCurrentRoute] = useState<AppRoute>(
token && user ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT
currentAccount ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT
);
const [selectedRepo, setSelectedRepo] = useState<Repository | null>(null);
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
Expand All @@ -42,11 +56,11 @@ 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);
setCurrentAccount(account);
setAccounts(getAccounts());
setCurrentRoute(AppRoute.REPO_LIST);
setAddingAccount(false);
};

const handleLogout = async () => {
Expand All @@ -57,12 +71,58 @@ 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
clearAllAccounts();
setCurrentAccount(null);
setAccounts([]);
setCurrentRoute(AppRoute.TOKEN_INPUT);
setSelectedRepo(null);
setAddingAccount(false);
};

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

const handleRemoveAccount = (accountId: string) => {
removeAccount(accountId);
const updatedAccounts = getAccounts();
setAccounts(updatedAccounts);

// If we removed the current account, update to the new active one
if (currentAccount?.id === accountId) {
const newActiveAccount = getActiveAccount();
setCurrentAccount(newActiveAccount);

// If no accounts left, go to login
if (!newActiveAccount) {
setCurrentRoute(AppRoute.TOKEN_INPUT);
setSelectedRepo(null);
setSelectedIssue(null);
} else {
// Reset to repo list
setCurrentRoute(AppRoute.REPO_LIST);
setSelectedRepo(null);
setSelectedIssue(null);
}
}
};

const handleAddAccount = () => {
setAddingAccount(true);
setCurrentRoute(AppRoute.TOKEN_INPUT);
};

const handleCancelAddAccount = () => {
setAddingAccount(false);
setCurrentRoute(AppRoute.REPO_LIST);
};

const navigateToRepo = (repo: Repository) => {
Expand Down Expand Up @@ -95,38 +155,49 @@ const App: React.FC = () => {
);
}

if (currentRoute === AppRoute.TOKEN_INPUT || !token || !user) {
return <TokenGate onSuccess={handleLogin} />;
if (currentRoute === AppRoute.TOKEN_INPUT || !currentAccount) {
return <TokenGate
onSuccess={handleLogin}
isAddingAccount={addingAccount}
onCancel={addingAccount ? handleCancelAddAccount : undefined}
/>;
}

if (currentRoute === AppRoute.ISSUE_DETAIL && selectedRepo && selectedIssue) {
return (
<IssueDetail
token={token}
token={currentAccount.token}
repo={selectedRepo}
issue={selectedIssue}
onBack={navigateBackToRepo}
accountId={currentAccount.id}
/>
);
}

if (currentRoute === AppRoute.REPO_DETAIL && selectedRepo) {
return (
<RepoDetail
token={token}
token={currentAccount.token}
repo={selectedRepo}
onBack={navigateBack}
onIssueSelect={navigateToIssue}
accountId={currentAccount.id}
/>
);
}

return (
<Dashboard
token={token}
user={user}
token={currentAccount.token}
user={currentAccount.user}
onRepoSelect={navigateToRepo}
onLogout={handleLogout}
accounts={accounts}
currentAccount={currentAccount}
onSwitchAccount={handleSwitchAccount}
onAddAccount={handleAddAccount}
onRemoveAccount={handleRemoveAccount}
/>
);
};
Expand Down
138 changes: 138 additions & 0 deletions components/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, Plus, Check, X } from 'lucide-react';
import { Account } from '../services/accountService';

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

export const AccountSwitcher: React.FC<AccountSwitcherProps> = ({
accounts,
currentAccount,
onSwitch,
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);
}
};

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

const handleRemoveAccount = (e: React.MouseEvent, accountId: string) => {
e.stopPropagation();
onRemoveAccount(accountId);
};

return (
<div className="relative" ref={dropdownRef}>
{/* Current Account Button */}
<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"
>
<img
src={currentAccount.user.avatar_url}
alt={currentAccount.user.login}
className="w-7 h-7 rounded-full border border-slate-200 dark:border-slate-600"
/>
<span className="font-medium text-slate-900 dark:text-slate-100 hidden sm:inline">
{currentAccount.user.login}
</span>
{accounts.length > 1 && (
<ChevronDown
size={16}
className={`text-slate-500 dark:text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
)}
</button>

{/* Dropdown Menu */}
{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">
{/* Accounts List */}
<div className="px-2 mb-2">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 px-3 py-2">
ACCOUNTS
</div>
{accounts.map(account => (
<div
key={account.id}
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer group ${
account.id === currentAccount.id
? 'bg-blue-50 dark:bg-blue-900/20'
: 'hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
onClick={() => {
if (account.id !== currentAccount.id) {
onSwitch(account.id);
setIsOpen(false);
}
}}
>
<img
src={account.user.avatar_url}
alt={account.user.login}
className="w-8 h-8 rounded-full border border-slate-200 dark:border-slate-600"
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900 dark:text-slate-100 truncate">
{account.user.login}
</div>
{account.user.name && (
<div className="text-xs text-slate-500 dark:text-slate-400 truncate">
{account.user.name}
</div>
)}
</div>
{account.id === currentAccount.id ? (
<Check size={16} className="text-blue-600 dark:text-blue-400" />
) : (
accounts.length > 1 && (
<button
onClick={(e) => handleRemoveAccount(e, account.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-opacity"
title="Remove account"
aria-label={`Remove account ${account.user.login}`}
>
<X size={14} className="text-red-600 dark:text-red-400" />
</button>
)
)}
</div>
))}
</div>

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