feat: implement navigation context and enhance component loading states across the application

This commit is contained in:
Arunavo Ray
2025-05-24 12:51:57 +05:30
parent a3ac31795c
commit 70b3e412ad
9 changed files with 229 additions and 48 deletions

View File

@@ -26,6 +26,7 @@ import { useFilterParams } from '@/hooks/useFilterParams';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh'; import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus'; import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout';
type MirrorJobWithKey = MirrorJob & { _rowKey: string }; type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -41,6 +42,7 @@ export function ActivityLog() {
const { user } = useAuth(); const { user } = useAuth();
const { registerRefreshCallback } = useLiveRefresh(); const { registerRefreshCallback } = useLiveRefresh();
const { isFullyConfigured } = useConfigStatus(); const { isFullyConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
const [activities, setActivities] = useState<MirrorJobWithKey[]>([]); const [activities, setActivities] = useState<MirrorJobWithKey[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -71,7 +73,7 @@ export function ActivityLog() {
/* ------------------------- initial fetch --------------------------- */ /* ------------------------- initial fetch --------------------------- */
const fetchActivities = useCallback(async () => { const fetchActivities = useCallback(async () => {
if (!user) return false; if (!user?.id) return false;
try { try {
setIsLoading(true); setIsLoading(true);
@@ -101,11 +103,13 @@ export function ActivityLog() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [user]); }, [user?.id]); // Only depend on user.id, not entire user object
useEffect(() => { useEffect(() => {
// Reset loading state when component becomes active
setIsLoading(true);
fetchActivities(); fetchActivities();
}, [fetchActivities]); }, [fetchActivities, navigationKey]); // Include navigationKey to trigger on navigation
// Register with global live refresh system // Register with global live refresh system
useEffect(() => { useEffect(() => {

View File

@@ -16,6 +16,7 @@ import { apiRequest } from '@/lib/utils';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { invalidateConfigCache } from '@/hooks/useConfigStatus';
type ConfigState = { type ConfigState = {
githubConfig: GitHubConfig; githubConfig: GitHubConfig;
@@ -117,6 +118,8 @@ export function ConfigTabs() {
if (result.success) { if (result.success) {
await refreshUser(); await refreshUser();
setIsConfigSaved(true); setIsConfigSaved(true);
// Invalidate config cache so other components get fresh data
invalidateConfigCache();
toast.success( toast.success(
'Configuration saved successfully! Now import your GitHub data to begin.', 'Configuration saved successfully! Now import your GitHub data to begin.',
); );
@@ -165,6 +168,8 @@ export function ConfigTabs() {
if (result.success) { if (result.success) {
// Silent success - no toast for auto-save // Silent success - no toast for auto-save
// Removed refreshUser() call to prevent page reload // Removed refreshUser() call to prevent page reload
// Invalidate config cache so other components get fresh data
invalidateConfigCache();
} else { } else {
toast.error( toast.error(
`Auto-save failed: ${result.message || 'Unknown error'}`, `Auto-save failed: ${result.message || 'Unknown error'}`,

View File

@@ -14,12 +14,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { usePageVisibility } from "@/hooks/usePageVisibility"; import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
export function Dashboard() { export function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const { registerRefreshCallback } = useLiveRefresh(); const { registerRefreshCallback } = useLiveRefresh();
const isPageVisible = usePageVisibility(); const isPageVisible = usePageVisibility();
const { isFullyConfigured } = useConfigStatus(); const { isFullyConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
const [repositories, setRepositories] = useState<Repository[]>([]); const [repositories, setRepositories] = useState<Repository[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -68,7 +70,7 @@ export function Dashboard() {
// Extract fetchDashboardData as a stable callback // Extract fetchDashboardData as a stable callback
const fetchDashboardData = useCallback(async (showToast = false) => { const fetchDashboardData = useCallback(async (showToast = false) => {
try { try {
if (!user || !user.id) { if (!user?.id) {
return false; return false;
} }
@@ -114,12 +116,14 @@ export function Dashboard() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [user, isFullyConfigured]); }, [user?.id, isFullyConfigured]); // Only depend on user.id, not entire user object
// Initial data fetch // Initial data fetch and reset loading state when component becomes active
useEffect(() => { useEffect(() => {
// Reset loading state when component mounts or becomes active
setIsLoading(true);
fetchDashboardData(); fetchDashboardData();
}, [fetchDashboardData]); }, [fetchDashboardData, navigationKey]); // Include navigationKey to trigger on navigation
// Setup dashboard auto-refresh (30 seconds) and register with live refresh // Setup dashboard auto-refresh (30 seconds) and register with live refresh
useEffect(() => { useEffect(() => {

View File

@@ -10,9 +10,10 @@ import { useConfigStatus } from "@/hooks/useConfigStatus";
interface HeaderProps { interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
onNavigate?: (page: string) => void;
} }
export function Header({ currentPage }: HeaderProps) { export function Header({ currentPage, onNavigate }: HeaderProps) {
const { user, logout, isLoading } = useAuth(); const { user, logout, isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh(); const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus(); const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -49,10 +50,18 @@ export function Header({ currentPage }: HeaderProps) {
return ( return (
<header className="border-b bg-background"> <header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-6"> <div className="flex h-[4.5rem] items-center justify-between px-6">
<a href="/" className="flex items-center gap-2 py-1"> <button
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<SiGitea className="h-6 w-6" /> <SiGitea className="h-6 w-6" />
<span className="text-xl font-bold">Gitea Mirror</span> <span className="text-xl font-bold">Gitea Mirror</span>
</a> </button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{showLiveButton && ( {showLiveButton && (

View File

@@ -1,3 +1,4 @@
import { useState, useEffect, createContext, useContext } from "react";
import { Header } from "./Header"; import { Header } from "./Header";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { Dashboard } from "@/components/dashboard/Dashboard"; import { Dashboard } from "@/components/dashboard/Dashboard";
@@ -9,6 +10,12 @@ import { Organization } from "../organizations/Organization";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRepoSync } from "@/hooks/useSyncRepo"; import { useRepoSync } from "@/hooks/useSyncRepo";
import { useConfigStatus } from "@/hooks/useConfigStatus";
// Navigation context to signal when navigation happens
const NavigationContext = createContext<{ navigationKey: number }>({ navigationKey: 0 });
export const useNavigation = () => useContext(NavigationContext);
interface AppProps { interface AppProps {
page: page:
@@ -32,8 +39,12 @@ export default function App({ page }: AppProps) {
); );
} }
function AppWithProviders({ page }: AppProps) { function AppWithProviders({ page: initialPage }: AppProps) {
const { user } = useAuth(); const { user, isLoading: authLoading } = useAuth();
const { isLoading: configLoading } = useConfigStatus();
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
const [navigationKey, setNavigationKey] = useState(0);
useRepoSync({ useRepoSync({
userId: user?.id, userId: user?.id,
enabled: user?.syncEnabled, enabled: user?.syncEnabled,
@@ -42,20 +53,63 @@ function AppWithProviders({ page }: AppProps) {
nextSync: user?.nextSync, nextSync: user?.nextSync,
}); });
// Handle navigation from sidebar
const handleNavigation = (pageName: string) => {
setCurrentPage(pageName as AppProps['page']);
// Increment navigation key to force components to refresh their loading state
setNavigationKey(prev => prev + 1);
};
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
const path = window.location.pathname;
const pageMap: Record<string, AppProps['page']> = {
'/': 'dashboard',
'/repositories': 'repositories',
'/organizations': 'organizations',
'/config': 'configuration',
'/activity': 'activity-log'
};
const pageName = pageMap[path] || 'dashboard';
setCurrentPage(pageName);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Show loading state only during initial auth/config loading
const isInitialLoading = authLoading || (configLoading && !user);
if (isInitialLoading) {
return (
<main className="flex min-h-screen flex-col items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</main>
);
}
return ( return (
<main className="flex min-h-screen flex-col"> <NavigationContext.Provider value={{ navigationKey }}>
<Header currentPage={page} /> <main className="flex min-h-screen flex-col">
<div className="flex flex-1"> <Header currentPage={currentPage} onNavigate={handleNavigation} />
<Sidebar /> <div className="flex flex-1">
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]"> <Sidebar onNavigate={handleNavigation} />
{page === "dashboard" && <Dashboard />} <section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
{page === "repositories" && <Repository />} {currentPage === "dashboard" && <Dashboard />}
{page === "organizations" && <Organization />} {currentPage === "repositories" && <Repository />}
{page === "configuration" && <ConfigTabs />} {currentPage === "organizations" && <Organization />}
{page === "activity-log" && <ActivityLog />} {currentPage === "configuration" && <ConfigTabs />}
</section> {currentPage === "activity-log" && <ActivityLog />}
</div> </section>
<Toaster /> </div>
</main> <Toaster />
</main>
</NavigationContext.Provider>
); );
} }

View File

@@ -6,9 +6,10 @@ import { VersionInfo } from "./VersionInfo";
interface SidebarProps { interface SidebarProps {
className?: string; className?: string;
onNavigate?: (page: string) => void;
} }
export function Sidebar({ className }: SidebarProps) { export function Sidebar({ className, onNavigate }: SidebarProps) {
const [currentPath, setCurrentPath] = useState<string>(""); const [currentPath, setCurrentPath] = useState<string>("");
useEffect(() => { useEffect(() => {
@@ -18,6 +19,39 @@ export function Sidebar({ className }: SidebarProps) {
console.log("Hydrated path:", path); // Should log now console.log("Hydrated path:", path); // Should log now
}, []); }, []);
// Listen for URL changes (browser back/forward)
useEffect(() => {
const handlePopState = () => {
setCurrentPath(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const handleNavigation = (href: string, event: React.MouseEvent) => {
event.preventDefault();
// Don't navigate if already on the same page
if (currentPath === href) return;
// Update URL without page reload
window.history.pushState({}, '', href);
setCurrentPath(href);
// Map href to page name for the parent component
const pageMap: Record<string, string> = {
'/': 'dashboard',
'/repositories': 'repositories',
'/organizations': 'organizations',
'/config': 'configuration',
'/activity': 'activity-log'
};
const pageName = pageMap[href] || 'dashboard';
onNavigate?.(pageName);
};
return ( return (
<aside className={cn("w-64 border-r bg-background", className)}> <aside className={cn("w-64 border-r bg-background", className)}>
<div className="flex flex-col h-full pt-4"> <div className="flex flex-col h-full pt-4">
@@ -27,11 +61,11 @@ export function Sidebar({ className }: SidebarProps) {
const Icon = link.icon; const Icon = link.icon;
return ( return (
<a <button
key={index} key={index}
href={link.href} onClick={(e) => handleNavigation(link.href, e)}
className={cn( className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left",
isActive isActive
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
@@ -39,7 +73,7 @@ export function Sidebar({ className }: SidebarProps) {
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{link.label} {link.label}
</a> </button>
); );
})} })}
</nav> </nav>

View File

@@ -26,6 +26,7 @@ import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
export function Organization() { export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -34,6 +35,7 @@ export function Organization() {
const { user } = useAuth(); const { user } = useAuth();
const { registerRefreshCallback } = useLiveRefresh(); const { registerRefreshCallback } = useLiveRefresh();
const { isGitHubConfigured } = useConfigStatus(); const { isGitHubConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
const { filter, setFilter } = useFilterParams({ const { filter, setFilter } = useFilterParams({
searchTerm: "", searchTerm: "",
membershipRole: "", membershipRole: "",
@@ -63,7 +65,7 @@ export function Organization() {
}); });
const fetchOrganizations = useCallback(async () => { const fetchOrganizations = useCallback(async () => {
if (!user || !user.id) { if (!user?.id) {
return false; return false;
} }
@@ -98,11 +100,13 @@ export function Organization() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [user, isGitHubConfigured]); }, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
useEffect(() => { useEffect(() => {
// Reset loading state when component becomes active
setIsLoading(true);
fetchOrganizations(); fetchOrganizations();
}, [fetchOrganizations]); }, [fetchOrganizations, navigationKey]); // Include navigationKey to trigger on navigation
// Register with global live refresh system // Register with global live refresh system
useEffect(() => { useEffect(() => {

View File

@@ -27,9 +27,10 @@ import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync";
import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes"; import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes";
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry"; import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
import AddRepositoryDialog from "./AddRepositoryDialog"; import AddRepositoryDialog from "./AddRepositoryDialog";
import type { ConfigApiResponse } from "@/types/config";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
export default function Repository() { export default function Repository() {
const [repositories, setRepositories] = useState<Repository[]>([]); const [repositories, setRepositories] = useState<Repository[]>([]);
@@ -37,6 +38,7 @@ export default function Repository() {
const { user } = useAuth(); const { user } = useAuth();
const { registerRefreshCallback } = useLiveRefresh(); const { registerRefreshCallback } = useLiveRefresh();
const { isGitHubConfigured } = useConfigStatus(); const { isGitHubConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
const { filter, setFilter } = useFilterParams({ const { filter, setFilter } = useFilterParams({
searchTerm: "", searchTerm: "",
status: "", status: "",
@@ -79,7 +81,7 @@ export default function Repository() {
}); });
const fetchRepositories = useCallback(async () => { const fetchRepositories = useCallback(async () => {
if (!user) return; if (!user?.id) return;
// Don't fetch repositories if GitHub is not configured or still loading config // Don't fetch repositories if GitHub is not configured or still loading config
if (!isGitHubConfigured) { if (!isGitHubConfigured) {
@@ -112,11 +114,13 @@ export default function Repository() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [user, isGitHubConfigured]); }, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
useEffect(() => { useEffect(() => {
// Reset loading state when component becomes active
setIsLoading(true);
fetchRepositories(); fetchRepositories();
}, [fetchRepositories]); }, [fetchRepositories, navigationKey]); // Include navigationKey to trigger on navigation
// Register with global live refresh system // Register with global live refresh system
useEffect(() => { useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react';
import { useAuth } from './useAuth'; import { useAuth } from './useAuth';
import { apiRequest } from '@/lib/utils'; import { apiRequest } from '@/lib/utils';
import type { ConfigApiResponse } from '@/types/config'; import type { ConfigApiResponse } from '@/types/config';
@@ -11,9 +11,19 @@ interface ConfigStatus {
error: string | null; error: string | null;
} }
// Cache to prevent duplicate API calls across components
let configCache: { data: ConfigApiResponse | null; timestamp: number; userId: string | null } = {
data: null,
timestamp: 0,
userId: null
};
const CACHE_DURATION = 30000; // 30 seconds cache
/** /**
* Hook to check if GitHub and Gitea are properly configured * Hook to check if GitHub and Gitea are properly configured
* Returns configuration status and prevents unnecessary API calls when not configured * Returns configuration status and prevents unnecessary API calls when not configured
* Uses caching to prevent duplicate API calls across components
*/ */
export function useConfigStatus(): ConfigStatus { export function useConfigStatus(): ConfigStatus {
const { user } = useAuth(); const { user } = useAuth();
@@ -25,6 +35,9 @@ export function useConfigStatus(): ConfigStatus {
error: null, error: null,
}); });
// Track if this hook has already checked config to prevent multiple calls
const hasCheckedRef = useRef(false);
const checkConfiguration = useCallback(async () => { const checkConfiguration = useCallback(async () => {
if (!user?.id) { if (!user?.id) {
setConfigStatus({ setConfigStatus({
@@ -37,22 +50,23 @@ export function useConfigStatus(): ConfigStatus {
return; return;
} }
try { // Check cache first
setConfigStatus(prev => ({ ...prev, isLoading: true, error: null })); const now = Date.now();
const isCacheValid = configCache.data &&
configCache.userId === user.id &&
(now - configCache.timestamp) < CACHE_DURATION;
const configResponse = await apiRequest<ConfigApiResponse>( if (isCacheValid && hasCheckedRef.current) {
`/config?userId=${user.id}`, const configResponse = configCache.data!;
{ method: 'GET' }
);
const isGitHubConfigured = !!( const isGitHubConfigured = !!(
configResponse?.githubConfig?.username && configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token configResponse?.githubConfig?.token
); );
const isGiteaConfigured = !!( const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url && configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username && configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token configResponse?.giteaConfig?.token
); );
@@ -65,6 +79,49 @@ export function useConfigStatus(): ConfigStatus {
isLoading: false, isLoading: false,
error: null, error: null,
}); });
return;
}
try {
// Only show loading if we haven't checked before or cache is invalid
if (!hasCheckedRef.current) {
setConfigStatus(prev => ({ ...prev, isLoading: true, error: null }));
}
const configResponse = await apiRequest<ConfigApiResponse>(
`/config?userId=${user.id}`,
{ method: 'GET' }
);
// Update cache
configCache = {
data: configResponse,
timestamp: now,
userId: user.id
};
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
const isFullyConfigured = isGitHubConfigured && isGiteaConfigured;
setConfigStatus({
isGitHubConfigured,
isGiteaConfigured,
isFullyConfigured,
isLoading: false,
error: null,
});
hasCheckedRef.current = true;
} catch (error) { } catch (error) {
setConfigStatus({ setConfigStatus({
isGitHubConfigured: false, isGitHubConfigured: false,
@@ -73,6 +130,7 @@ export function useConfigStatus(): ConfigStatus {
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Failed to check configuration', error: error instanceof Error ? error.message : 'Failed to check configuration',
}); });
hasCheckedRef.current = true;
} }
}, [user?.id]); }, [user?.id]);
@@ -82,3 +140,8 @@ export function useConfigStatus(): ConfigStatus {
return configStatus; return configStatus;
} }
// Export function to invalidate cache when config is updated
export function invalidateConfigCache() {
configCache = { data: null, timestamp: 0, userId: null };
}