diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 08cbb9b..9f46503 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,10 +1,16 @@ +import { Routes, Route, Navigate } from 'react-router-dom' import { DashboardLayout } from './dashboard/DashboardLayout' import { DashboardHome } from './dashboard/DashboardHome' +import { SettingsPage } from '../pages/SettingsPage' export function Dashboard() { return ( - + + } /> + } /> + } /> + ) } diff --git a/frontend/src/components/dashboard/Sidebar.tsx b/frontend/src/components/dashboard/Sidebar.tsx index 47f9457..48c557b 100644 --- a/frontend/src/components/dashboard/Sidebar.tsx +++ b/frontend/src/components/dashboard/Sidebar.tsx @@ -1,8 +1,6 @@ import { Link, useLocation } from 'react-router-dom' import { FolderGit2, - Search, - Settings, BookOpen, ChevronLeft, ChevronRight, @@ -26,12 +24,10 @@ interface NavItem { const mainNavItems: NavItem[] = [ { name: 'Repositories', href: '/dashboard', icon: }, - { name: 'Global Search', href: '/dashboard/search', icon: }, ] const bottomNavItems: NavItem[] = [ { name: 'Documentation', href: '/docs', icon: , external: true }, - { name: 'Settings', href: '/dashboard/settings', icon: }, ] export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { diff --git a/frontend/src/components/dashboard/TopNav.tsx b/frontend/src/components/dashboard/TopNav.tsx index 2f3ed17..84891c2 100644 --- a/frontend/src/components/dashboard/TopNav.tsx +++ b/frontend/src/components/dashboard/TopNav.tsx @@ -1,19 +1,16 @@ import { Link } from 'react-router-dom' import { useAuth } from '../../contexts/AuthContext' -import { useState } from 'react' -import { - Menu, - Search, - Github, - Sun, - Moon, - LogOut, - Settings, - BookOpen, - ExternalLink -} from 'lucide-react' +import { Menu, Search, Github, Sun, Moon, LogOut, Settings, BookOpen, ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' interface TopNavProps { onToggleSidebar: () => void @@ -24,7 +21,6 @@ interface TopNavProps { export function TopNav({ onToggleSidebar, sidebarCollapsed, onOpenCommandPalette }: TopNavProps) { const { session, signOut } = useAuth() const { theme, setTheme } = useTheme() - const [showUserMenu, setShowUserMenu] = useState(false) const userEmail = session?.user?.email || 'User' const userInitial = userEmail.charAt(0).toUpperCase() @@ -55,7 +51,7 @@ export function TopNav({ onToggleSidebar, sidebarCollapsed, onOpenCommandPalette {/* Center - Command Palette Trigger */} - - {/* User Menu */} -
- - - {showUserMenu && ( - <> -
setShowUserMenu(false)} - /> -
-
-

{userEmail}

-

Free Plan

-
-
- setShowUserMenu(false)} - > - - Settings - - setShowUserMenu(false)} - > - - Documentation - - -
-
- -
+ {/* User Menu - using shadcn DropdownMenu for proper click-outside handling */} + + + + + + +
+

{userEmail}

+

Free Plan

- - )} -
+ + + + + + Settings + + + + + + Documentation + + + + + signOut()} + className="text-destructive focus:text-destructive cursor-pointer" + > + + Sign out + + +
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..c3ef9d2 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,326 @@ +import { useEffect, useState } from 'react' +import { User, Github, FolderGit2, AlertTriangle, Loader2, Check } from 'lucide-react' +import { useAuth } from '@/contexts/AuthContext' +import { useGitHubRepos } from '@/hooks/useGitHubRepos' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/Skeleton' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { toast } from 'sonner' +import { API_URL } from '@/config/api' + +interface Repository { + id: string + name: string +} + +const MAX_REPOS = 3 +const DELETE_CONFIRMATION_TEXT = 'delete all' + +export function SettingsPage() { + const { user, session } = useAuth() + const { status, checkStatus, disconnect, loading: githubLoading } = useGitHubRepos() + + const [repos, setRepos] = useState([]) + const [reposLoading, setReposLoading] = useState(true) + const [disconnectLoading, setDisconnectLoading] = useState(false) + const [deleteReposDialog, setDeleteReposDialog] = useState(false) + const [deleteReposLoading, setDeleteReposLoading] = useState(false) + const [deleteConfirmation, setDeleteConfirmation] = useState('') + + useEffect(() => { + checkStatus() + fetchRepos() + }, [checkStatus, session?.access_token]) + + const fetchRepos = async () => { + if (!session?.access_token) { + setReposLoading(false) + return + } + setReposLoading(true) + try { + const response = await fetch(`${API_URL}/repos`, { + headers: { Authorization: `Bearer ${session.access_token}` }, + }) + + if (!response.ok) { + const errorBody = await response.text() + console.error('Failed to fetch repos:', response.status, errorBody) + return + } + + const data = await response.json() + setRepos(data.repositories || []) + } catch (error) { + console.error('Failed to fetch repos:', error) + } finally { + setReposLoading(false) + } + } + + const handleDisconnectGitHub = async () => { + setDisconnectLoading(true) + const success = await disconnect() + setDisconnectLoading(false) + if (success) { + toast.success('GitHub disconnected successfully') + } else { + toast.error('Failed to disconnect GitHub') + } + } + + const handleDeleteAllRepos = async () => { + if (deleteConfirmation !== DELETE_CONFIRMATION_TEXT) return + + setDeleteReposLoading(true) + const failedRepos: string[] = [] + + try { + for (const repo of repos) { + const response = await fetch(`${API_URL}/repos/${repo.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${session?.access_token}` }, + }) + + if (!response.ok) { + failedRepos.push(repo.name) + } + } + + if (failedRepos.length > 0) { + // Partial failure - refetch to get accurate state + await fetchRepos() + toast.error(`Failed to delete ${failedRepos.length} repo(s): ${failedRepos.join(', ')}`) + } else { + // All succeeded + setRepos([]) + setDeleteReposDialog(false) + setDeleteConfirmation('') + toast.success('All repositories deleted') + } + } catch (error) { + // Network or unexpected error - refetch to reconcile state + await fetchRepos() + toast.error('Failed to delete repositories') + } finally { + setDeleteReposLoading(false) + } + } + + const handleCloseDeleteDialog = () => { + setDeleteReposDialog(false) + setDeleteConfirmation('') + } + + const formatDate = (dateString: string | undefined) => { + if (!dateString) return 'Unknown' + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + } + + const isDeleteEnabled = deleteConfirmation === DELETE_CONFIRMATION_TEXT + const availableSlots = Math.max(0, MAX_REPOS - repos.length) + + return ( +
+
+

Settings

+

Manage your account and connections

+
+ + {/* Profile Section */} + + +
+
+ +
+
+ Profile + Your account information +
+
+
+ +
+ Email + {user?.email || 'Not set'} +
+ +
+ Member since + {formatDate(user?.created_at)} +
+
+
+ + {/* Connections Section */} + + +
+
+ +
+
+ Connections + Manage connected accounts +
+
+
+ +
+
+ +
+

GitHub

+ {githubLoading ? ( + + ) : status?.connected ? ( +

+ Connected as @{status.username} +

+ ) : ( +

Not connected

+ )} +
+
+ + {githubLoading ? ( + + ) : status?.connected ? ( + + ) : ( + + + Use dashboard import + + )} +
+
+
+ + {/* Repositories Section */} + + +
+
+ +
+
+ Repositories + Your indexed repository slots +
+
+
+ +
+
+

Repository slots

+

Free tier limit

+
+
+ {reposLoading ? ( + + ) : ( + <> +

+ {repos.length} + / {MAX_REPOS} +

+

+ {availableSlots} slot{availableSlots !== 1 ? 's' : ''} available +

+ + )} +
+
+
+
+ + {/* Danger Zone */} + + + Danger Zone + +
+
+

Delete all repositories

+

Remove all indexed repos and their data

+
+ +
+
+
+ + {/* Delete Repos Dialog with typing confirmation */} + + + + Delete all repositories? + + This will permanently delete {repos.length} repositor{repos.length === 1 ? 'y' : 'ies'} and + all indexed data. This action cannot be undone. + + +
+ + setDeleteConfirmation(e.target.value)} + placeholder={DELETE_CONFIRMATION_TEXT} + className="font-mono" + autoComplete="off" + /> +
+ + + + +
+
+
+ ) +}