mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
feat(ui): add navigation progress bar for better UX during page transitions (#9465)
This commit is contained in:
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465)
|
||||
- Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405)
|
||||
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import "@/styles/globals.css";
|
||||
import { GoogleTagManager } from "@next/third-parties/google";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { Toaster } from "@/components/ui";
|
||||
import { NavigationProgress, Toaster } from "@/components/ui";
|
||||
import { fontSans } from "@/config/fonts";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { cn } from "@/lib";
|
||||
@@ -33,7 +34,7 @@ export const viewport: Viewport = {
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
|
||||
@@ -52,6 +53,7 @@ export default async function RootLayout({
|
||||
)}
|
||||
>
|
||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||
<NavigationProgress />
|
||||
{children}
|
||||
<Toaster />
|
||||
<GoogleTagManager
|
||||
|
||||
@@ -2,10 +2,11 @@ import "@/styles/globals.css";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Metadata, Viewport } from "next";
|
||||
import React from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import MainLayout from "@/components/ui/main-layout/main-layout";
|
||||
import { NavigationProgress } from "@/components/ui/navigation-progress";
|
||||
import { Toaster } from "@/components/ui/toast";
|
||||
import { fontSans } from "@/config/fonts";
|
||||
import { siteConfig } from "@/config/site";
|
||||
@@ -38,7 +39,7 @@ export const viewport: Viewport = {
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const providersData = await getProviders({ page: 1, pageSize: 1 });
|
||||
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
|
||||
@@ -54,6 +55,7 @@ export default async function RootLayout({
|
||||
)}
|
||||
>
|
||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||
<NavigationProgress />
|
||||
<StoreInitializer values={{ hasProviders }} />
|
||||
<MainLayout>{children}</MainLayout>
|
||||
<Toaster />
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from "./feedback-banner/feedback-banner";
|
||||
export * from "./headers/navigation-header";
|
||||
export * from "./label/Label";
|
||||
export * from "./main-layout/main-layout";
|
||||
export * from "./navigation-progress";
|
||||
export * from "./select";
|
||||
export * from "./sidebar";
|
||||
export * from "./toast";
|
||||
|
||||
7
ui/components/ui/navigation-progress/index.ts
Normal file
7
ui/components/ui/navigation-progress/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { NavigationProgress } from "./navigation-progress";
|
||||
export {
|
||||
cancelProgress,
|
||||
completeProgress,
|
||||
startProgress,
|
||||
useNavigationProgress,
|
||||
} from "./use-navigation-progress";
|
||||
42
ui/components/ui/navigation-progress/navigation-progress.tsx
Normal file
42
ui/components/ui/navigation-progress/navigation-progress.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib";
|
||||
|
||||
import { useNavigationProgress } from "./use-navigation-progress";
|
||||
|
||||
const HIDE_DELAY_MS = 200;
|
||||
|
||||
export function NavigationProgress() {
|
||||
const { isLoading, progress } = useNavigationProgress();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return setVisible(true);
|
||||
|
||||
const timeout = setTimeout(() => setVisible(false), HIDE_DELAY_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isLoading]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 z-[99999] h-[3px] w-full"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Page loading progress"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-button-primary h-full transition-all duration-200 ease-out",
|
||||
isLoading && "shadow-progress-glow",
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
ui/components/ui/navigation-progress/use-navigation-progress.ts
Normal file
106
ui/components/ui/navigation-progress/use-navigation-progress.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
interface ProgressState {
|
||||
isLoading: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let state: ProgressState = { isLoading: false, progress: 0 };
|
||||
const listeners = new Set<() => void>();
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Cached server snapshot to avoid infinite loop with useSyncExternalStore
|
||||
const SERVER_SNAPSHOT: ProgressState = { isLoading: false, progress: 0 };
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function setState(newState: ProgressState) {
|
||||
state = newState;
|
||||
notify();
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the progress bar animation.
|
||||
* Progress increases quickly at first, then slows down as it approaches 90%.
|
||||
*/
|
||||
export function startProgress() {
|
||||
clearTimers();
|
||||
setState({ isLoading: true, progress: 0 });
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
if (state.progress < 90) {
|
||||
const increment = (90 - state.progress) * 0.1;
|
||||
setState({
|
||||
...state,
|
||||
progress: Math.min(90, state.progress + increment),
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the progress bar animation.
|
||||
* Jumps to 100% and then hides after a brief delay.
|
||||
*/
|
||||
export function completeProgress() {
|
||||
clearTimers();
|
||||
setState({ isLoading: false, progress: 100 });
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
setState({ isLoading: false, progress: 0 });
|
||||
timeoutId = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the progress bar immediately without animation.
|
||||
*/
|
||||
export function cancelProgress() {
|
||||
clearTimers();
|
||||
setState({ isLoading: false, progress: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access progress bar state.
|
||||
* Automatically completes progress when URL changes.
|
||||
*/
|
||||
export function useNavigationProgress() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentState = useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => state,
|
||||
() => SERVER_SNAPSHOT,
|
||||
);
|
||||
|
||||
// Complete progress when URL changes (only if currently loading)
|
||||
useEffect(() => {
|
||||
if (state.isLoading) {
|
||||
completeProgress();
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return currentState;
|
||||
}
|
||||
44
ui/instrumentation-client.ts
Normal file
44
ui/instrumentation-client.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Next.js Client Instrumentation
|
||||
*
|
||||
* This file runs on the client before React hydration.
|
||||
* Used to set up navigation progress tracking.
|
||||
*
|
||||
* @see https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
*/
|
||||
|
||||
import {
|
||||
cancelProgress,
|
||||
startProgress,
|
||||
} from "@/components/ui/navigation-progress/use-navigation-progress";
|
||||
|
||||
const NAVIGATION_TYPE = {
|
||||
PUSH: "push",
|
||||
REPLACE: "replace",
|
||||
TRAVERSE: "traverse",
|
||||
} as const;
|
||||
|
||||
type NavigationType = (typeof NAVIGATION_TYPE)[keyof typeof NAVIGATION_TYPE];
|
||||
|
||||
function getCurrentUrl(): string {
|
||||
return window.location.pathname + window.location.search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Next.js when router navigation begins.
|
||||
* Triggers the navigation progress bar.
|
||||
*/
|
||||
export function onRouterTransitionStart(
|
||||
url: string,
|
||||
_navigationType: NavigationType,
|
||||
) {
|
||||
const currentUrl = getCurrentUrl();
|
||||
|
||||
if (url === currentUrl) {
|
||||
// Same URL - cancel any ongoing progress
|
||||
cancelProgress();
|
||||
} else {
|
||||
// Different URL - start progress
|
||||
startProgress();
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,9 @@
|
||||
|
||||
/* Chart Dots */
|
||||
--chart-dots: var(--color-neutral-200);
|
||||
|
||||
/* Progress Bar */
|
||||
--shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
|
||||
}
|
||||
|
||||
/* ===== DARK THEME ===== */
|
||||
@@ -144,6 +147,9 @@
|
||||
|
||||
/* Chart Dots */
|
||||
--chart-dots: var(--text-neutral-primary);
|
||||
|
||||
/* Progress Bar */
|
||||
--shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
|
||||
}
|
||||
|
||||
/* ===== TAILWIND THEME MAPPINGS ===== */
|
||||
@@ -211,6 +217,9 @@
|
||||
--color-bg-warning: var(--bg-warning-primary);
|
||||
--color-bg-fail: var(--bg-fail-primary);
|
||||
--color-bg-fail-secondary: var(--bg-fail-secondary);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-progress-glow: var(--shadow-progress-glow);
|
||||
}
|
||||
|
||||
/* ===== CONTAINER UTILITY ===== */
|
||||
|
||||
Reference in New Issue
Block a user