Skip to content
Open
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
95 changes: 72 additions & 23 deletions App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,18 +9,26 @@ import { validateToken } from './services/githubService';
import { ThemeProvider } from './contexts/ThemeContext';

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
);
const [accounts, setAccounts] = useState<Account[]>(() => {
const stored = localStorage.getItem('gh_accounts');
return stored ? JSON.parse(stored) : [];
});
const [currentAccountId, setCurrentAccountId] = useState<string | null>(() => {
return localStorage.getItem('gh_current_account');
});
const [checkingRedirect, setCheckingRedirect] = useState(true);

const [currentRoute, setCurrentRoute] = useState<AppRoute>(
token && user ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT
currentAccountId && accounts.length > 0 ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT
);
const [selectedRepo, setSelectedRepo] = useState<Repository | null>(null);
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(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 () => {
Expand All @@ -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);
};

Expand All @@ -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 (
Expand All @@ -95,38 +137,45 @@ const App: React.FC = () => {
);
}

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

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

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

return (
<Dashboard
token={token}
user={user}
token={currentToken}
user={currentUser}
accounts={accounts}
currentAccountId={currentAccountId}
onRepoSelect={navigateToRepo}
onLogout={handleLogout}
onSwitchAccount={switchAccount}
onAddAccount={() => setCurrentRoute(AppRoute.TOKEN_INPUT)}
accountId={currentAccountId || ''}
/>
);
};
Expand Down
14 changes: 7 additions & 7 deletions services/cacheService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
122 changes: 97 additions & 25 deletions views/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,47 @@
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<void>;
onLogout: (accountId?: string) => void | Promise<void>;
onSwitchAccount: (accountId: string) => void;
onAddAccount: () => void;
accountId: string;
}

export const Dashboard: React.FC<DashboardProps> = ({ token, user, onRepoSelect, onLogout }) => {
export const Dashboard: React.FC<DashboardProps> = ({ token, user, accounts, currentAccountId, onRepoSelect, onLogout, onSwitchAccount, onAddAccount, accountId }) => {
const { toasts, dismissToast, showError } = useToast();

// Initialize from cache for instant display
const [repos, setRepos] = useState<Repository[]>(() => {
return getCached<Repository[]>(CacheKeys.repos()) || [];
return getCached<Repository[]>(CacheKeys.repos(accountId)) || [];
});
const [loading, setLoading] = useState(() => {
// Only show loading if no cached data
return !getCached<Repository[]>(CacheKeys.repos());
return !getCached<Repository[]>(CacheKeys.repos(accountId));
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState('');
const [pinnedRepoIds, setPinnedRepoIds] = useState<Set<number>>(() => {
const saved = localStorage.getItem('pinnedRepos');
return saved ? new Set(JSON.parse(saved)) : new Set();
});
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Initialize issues from cache for instant display
const [repoIssues, setRepoIssues] = useState<Record<number, Issue[]>>(() => {
const cachedRepos = getCached<Repository[]>(CacheKeys.repos());
const cachedRepos = getCached<Repository[]>(CacheKeys.repos(accountId));
if (!cachedRepos) return {};

const issuesMap: Record<number, Issue[]> = {};
Expand Down Expand Up @@ -91,7 +98,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ 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);
Expand Down Expand Up @@ -122,10 +129,24 @@ export const Dashboard: React.FC<DashboardProps> = ({ 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();
Expand Down Expand Up @@ -204,21 +225,72 @@ export const Dashboard: React.FC<DashboardProps> = ({ token, user, onRepoSelect,
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<ToastContainer toasts={toasts} onDismiss={dismissToast} />

{/* Header */}
<header className="bg-white dark:bg-slate-800 shadow-sm sticky top-0 z-10 border-b border-slate-200 dark:border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<div className="flex items-center gap-3">
<img src={user.avatar_url} alt={user.login} className="w-8 h-8 rounded-full border border-slate-200 dark:border-slate-600" />
<span className="font-semibold text-slate-900 dark:text-slate-100">{user.login}</span>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="ghost" onClick={onLogout} icon={<LogOut size={16} />}>
Sign Out
</Button>
</div>
</div>
</header>
{/* Header */}
<header className="bg-white dark:bg-slate-800 shadow-sm sticky top-0 z-10 border-b border-slate-200 dark:border-slate-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
{/* Account Switcher */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsAccountDropdownOpen(!isAccountDropdownOpen)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<img src={user.avatar_url} alt={user.login} className="w-8 h-8 rounded-full border border-slate-200 dark:border-slate-600" />
<span className="font-semibold text-slate-900 dark:text-slate-100">{user.login}</span>
<ChevronDown size={16} className="text-slate-500 dark:text-slate-400" />
</button>

{isAccountDropdownOpen && (
<div className="absolute top-full left-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-slate-200 dark:border-slate-700 z-20">
<div className="p-2">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide px-2 py-1">
Switch Account
</div>
{accounts.map((account) => (
<button
key={account.id}
onClick={() => {
onSwitchAccount(account.id);
setIsAccountDropdownOpen(false);
}}
className={`w-full flex items-center gap-3 p-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors ${
account.id === currentAccountId ? 'bg-slate-50 dark:bg-slate-700' : ''
}`}
>
<img src={account.user.avatar_url} alt={account.user.login} className="w-6 h-6 rounded-full border border-slate-200 dark:border-slate-600" />
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{account.user.login}</span>
{account.id === currentAccountId && (
<span className="ml-auto text-xs text-slate-500 dark:text-slate-400">Current</span>
)}
</button>
))}

<div className="border-t border-slate-200 dark:border-slate-700 my-2"></div>

<button
onClick={() => {
onAddAccount();
setIsAccountDropdownOpen(false);
}}
className="w-full flex items-center gap-3 p-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<div className="w-6 h-6 rounded-full bg-slate-200 dark:bg-slate-600 flex items-center justify-center">
<UserPlus size={12} className="text-slate-600 dark:text-slate-400" />
</div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">Add Account</span>
</button>
</div>
</div>
)}
</div>

<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="ghost" onClick={() => onLogout(currentAccountId || undefined)} icon={<LogOut size={16} />}>
Sign Out
</Button>
</div>
</div>
</header>

<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
Expand Down
Loading