From 0890ed0bb841aecfbe5a6bfaa997422aaf91be5d Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Sat, 24 May 2025 10:24:25 +0530 Subject: [PATCH] feat: add live refresh functionality and configuration status hooks; enhance UI components with new switch and refresh features --- bun.lock | 7 + package.json | 1 + src/components/activity/ActivityList.tsx | 7 +- src/components/activity/ActivityLog.tsx | 29 +++- src/components/dashboard/Dashboard.tsx | 128 +++++++++++++----- src/components/layout/Header.tsx | 42 +++++- src/components/layout/MainLayout.tsx | 2 +- src/components/layout/Providers.tsx | 9 +- src/components/organizations/Organization.tsx | 36 ++++- src/components/repositories/Repository.tsx | 55 ++++---- src/components/ui/switch.tsx | 29 ++++ src/hooks/useConfigStatus.ts | 84 ++++++++++++ src/hooks/useLiveRefresh.ts | 102 ++++++++++++++ src/hooks/usePageVisibility.ts | 28 ++++ 14 files changed, 485 insertions(+), 74 deletions(-) create mode 100644 src/components/ui/switch.tsx create mode 100644 src/hooks/useConfigStatus.ts create mode 100644 src/hooks/useLiveRefresh.ts create mode 100644 src/hooks/usePageVisibility.ts diff --git a/bun.lock b/bun.lock index d165a9f..c4c412a 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.6", "@tailwindcss/vite": "^4.1.7", @@ -334,6 +335,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-roving-focus": "1.1.9", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4FiKSVoXqPP/KfzlB7lwwqoFV6EPwkrrqGp9cUYXjwDYHhvpnqq79P+EPHKcdoTE7Rl8w/+6s9rTlsfXHES9GA=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.6", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zYb+9dc9tkoN2JjBDIIPLQtk3gGyz8FMKoqYTb8EMVQ5a5hBcdHPECrsZVI4NpPAUOixhkoqg7Hj5ry5USowfA=="], @@ -1672,6 +1675,8 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], @@ -1768,6 +1773,8 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index e4ddc06..bf30b1e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-radio-group": "^1.3.6", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.6", "@tailwindcss/vite": "^4.1.7", diff --git a/src/components/activity/ActivityList.tsx b/src/components/activity/ActivityList.tsx index cfe7c37..788ea35 100644 --- a/src/components/activity/ActivityList.tsx +++ b/src/components/activity/ActivityList.tsx @@ -105,7 +105,7 @@ export default function ActivityList({ ? 'Try adjusting your search or filter criteria.' : 'No mirroring activities have been recorded yet.'}

- {hasFilter ? ( + {hasFilter && ( - ) : ( - )} ); diff --git a/src/components/activity/ActivityLog.tsx b/src/components/activity/ActivityLog.tsx index aa717f4..380ae4b 100644 --- a/src/components/activity/ActivityLog.tsx +++ b/src/components/activity/ActivityLog.tsx @@ -24,6 +24,8 @@ import { ActivityNameCombobox } from './ActivityNameCombobox'; import { useSSE } from '@/hooks/useSEE'; import { useFilterParams } from '@/hooks/useFilterParams'; import { toast } from 'sonner'; +import { useLiveRefresh } from '@/hooks/useLiveRefresh'; +import { useConfigStatus } from '@/hooks/useConfigStatus'; type MirrorJobWithKey = MirrorJob & { _rowKey: string }; @@ -37,6 +39,8 @@ function genKey(job: MirrorJob): string { export function ActivityLog() { const { user } = useAuth(); + const { registerRefreshCallback } = useLiveRefresh(); + const { isFullyConfigured } = useConfigStatus(); const [activities, setActivities] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -103,6 +107,21 @@ export function ActivityLog() { fetchActivities(); }, [fetchActivities]); + // Register with global live refresh system + useEffect(() => { + // Only register for live refresh if configuration is complete + // Activity logs can exist from previous runs, but new activities won't be generated without config + if (!isFullyConfigured) { + return; + } + + const unregister = registerRefreshCallback(() => { + fetchActivities(); + }); + + return unregister; + }, [registerRefreshCallback, fetchActivities, isFullyConfigured]); + /* ---------------------- filtering + exporting ---------------------- */ const applyLightFilter = (list: MirrorJobWithKey[]) => { @@ -277,9 +296,13 @@ export function ActivityLog() { {/* refresh */} - diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 3028e0d..1115c7d 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -2,7 +2,7 @@ import { StatusCard } from "./StatusCard"; import { RecentActivity } from "./RecentActivity"; import { RepositoryList } from "./RepositoryList"; import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { MirrorJob, Organization, Repository } from "@/lib/db/schema"; import { useAuth } from "@/hooks/useAuth"; import { apiRequest } from "@/lib/utils"; @@ -11,9 +11,16 @@ import { useSSE } from "@/hooks/useSEE"; import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useLiveRefresh } from "@/hooks/useLiveRefresh"; +import { usePageVisibility } from "@/hooks/usePageVisibility"; +import { useConfigStatus } from "@/hooks/useConfigStatus"; export function Dashboard() { const { user } = useAuth(); + const { registerRefreshCallback } = useLiveRefresh(); + const isPageVisible = usePageVisibility(); + const { isFullyConfigured } = useConfigStatus(); + const [repositories, setRepositories] = useState([]); const [organizations, setOrganizations] = useState([]); const [activities, setActivities] = useState([]); @@ -23,6 +30,10 @@ export function Dashboard() { const [mirroredCount, setMirroredCount] = useState(0); const [lastSync, setLastSync] = useState(null); + // Dashboard auto-refresh timer (30 seconds) + const dashboardTimerRef = useRef(null); + const DASHBOARD_REFRESH_INTERVAL = 30000; // 30 seconds + // Create a stable callback using useCallback const handleNewMessage = useCallback((data: MirrorJob) => { if (data.repositoryId) { @@ -54,44 +65,94 @@ export function Dashboard() { onMessage: handleNewMessage, }); + // Extract fetchDashboardData as a stable callback + const fetchDashboardData = useCallback(async (showToast = false) => { + try { + if (!user || !user.id) { + return false; + } + + // Don't fetch data if configuration is not complete + if (!isFullyConfigured) { + if (showToast) { + toast.info("Please configure GitHub and Gitea settings first"); + } + return false; + } + + const response = await apiRequest( + `/dashboard?userId=${user.id}`, + { + method: "GET", + } + ); + + if (response.success) { + setRepositories(response.repositories); + setOrganizations(response.organizations); + setActivities(response.activities); + setRepoCount(response.repoCount); + setOrgCount(response.orgCount); + setMirroredCount(response.mirroredCount); + setLastSync(response.lastSync); + + if (showToast) { + toast.success("Dashboard data refreshed successfully"); + } + return true; + } else { + toast.error(response.error || "Error fetching dashboard data"); + return false; + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Error fetching dashboard data" + ); + return false; + } finally { + setIsLoading(false); + } + }, [user, isFullyConfigured]); + + // Initial data fetch useEffect(() => { - const fetchDashboardData = async () => { - try { - if (!user || !user.id) { - return; - } + fetchDashboardData(); + }, [fetchDashboardData]); - const response = await apiRequest( - `/dashboard?userId=${user.id}`, - { - method: "GET", - } - ); + // Setup dashboard auto-refresh (30 seconds) and register with live refresh + useEffect(() => { + // Clear any existing timer + if (dashboardTimerRef.current) { + clearInterval(dashboardTimerRef.current); + dashboardTimerRef.current = null; + } - if (response.success) { - setRepositories(response.repositories); - setOrganizations(response.organizations); - setActivities(response.activities); - setRepoCount(response.repoCount); - setOrgCount(response.orgCount); - setMirroredCount(response.mirroredCount); - setLastSync(response.lastSync); - } else { - toast.error(response.error || "Error fetching dashboard data"); - } - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Error fetching dashboard data" - ); - } finally { - setIsLoading(false); + // Set up 30-second auto-refresh only when page is visible and configuration is complete + if (isPageVisible && isFullyConfigured) { + dashboardTimerRef.current = setInterval(() => { + fetchDashboardData(); + }, DASHBOARD_REFRESH_INTERVAL); + } + + // Cleanup on unmount or when page becomes invisible + return () => { + if (dashboardTimerRef.current) { + clearInterval(dashboardTimerRef.current); + dashboardTimerRef.current = null; } }; + }, [isPageVisible, isFullyConfigured, fetchDashboardData]); - fetchDashboardData(); - }, [user]); + // Register with global live refresh system + useEffect(() => { + const unregister = registerRefreshCallback(() => { + fetchDashboardData(); + }); + + return unregister; + }, [registerRefreshCallback, fetchDashboardData]); // Status Card Skeleton component function StatusCardSkeleton() { @@ -150,6 +211,7 @@ export function Dashboard() { ) : (
+
{ + if (!isFullyConfigured && !configLoading) { + return 'Configure GitHub and Gitea settings to enable live refresh'; + } + return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh'; + }; const handleLogout = async () => { toast.success("Logged out successfully"); @@ -35,6 +55,26 @@ export function Header() {
+ {showLiveButton && ( + + )} + {isLoading ? ( diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 3506c11..6b1f34b 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -44,7 +44,7 @@ function AppWithProviders({ page }: AppProps) { return (
-
+
diff --git a/src/components/layout/Providers.tsx b/src/components/layout/Providers.tsx index 59ecbbe..28bed09 100644 --- a/src/components/layout/Providers.tsx +++ b/src/components/layout/Providers.tsx @@ -1,13 +1,16 @@ import * as React from "react"; import { AuthProvider } from "@/hooks/useAuth"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { LiveRefreshProvider } from "@/hooks/useLiveRefresh"; export default function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index b9efe63..716b173 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -24,12 +24,16 @@ import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror"; import { useSSE } from "@/hooks/useSEE"; import { useFilterParams } from "@/hooks/useFilterParams"; import { toast } from "sonner"; +import { useLiveRefresh } from "@/hooks/useLiveRefresh"; +import { useConfigStatus } from "@/hooks/useConfigStatus"; export function Organization() { const [organizations, setOrganizations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(false); const { user } = useAuth(); + const { registerRefreshCallback } = useLiveRefresh(); + const { isGitHubConfigured } = useConfigStatus(); const { filter, setFilter } = useFilterParams({ searchTerm: "", membershipRole: "", @@ -63,6 +67,12 @@ export function Organization() { return false; } + // Don't fetch organizations if GitHub is not configured + if (!isGitHubConfigured) { + setIsLoading(false); + return false; + } + try { setIsLoading(true); @@ -88,12 +98,26 @@ export function Organization() { } finally { setIsLoading(false); } - }, [user]); + }, [user, isGitHubConfigured]); useEffect(() => { fetchOrganizations(); }, [fetchOrganizations]); + // Register with global live refresh system + useEffect(() => { + // Only register for live refresh if GitHub is configured + if (!isGitHubConfigured) { + return; + } + + const unregister = registerRefreshCallback(() => { + fetchOrganizations(); + }); + + return unregister; + }, [registerRefreshCallback, fetchOrganizations, isGitHubConfigured]); + const handleRefresh = async () => { const success = await fetchOrganizations(); if (success) { @@ -342,9 +366,13 @@ export function Organization() { -