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
|
### 🚀 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)
|
- 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)
|
- 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 { GoogleTagManager } from "@next/third-parties/google";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { auth } from "@/auth.config";
|
import { auth } from "@/auth.config";
|
||||||
import { Toaster } from "@/components/ui";
|
import { NavigationProgress, Toaster } from "@/components/ui";
|
||||||
import { fontSans } from "@/config/fonts";
|
import { fontSans } from "@/config/fonts";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import { cn } from "@/lib";
|
import { cn } from "@/lib";
|
||||||
@@ -33,7 +34,7 @@ export const viewport: Viewport = {
|
|||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export default async function RootLayout({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||||
|
<NavigationProgress />
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<GoogleTagManager
|
<GoogleTagManager
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import "@/styles/globals.css";
|
|||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import React from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import { getProviders } from "@/actions/providers";
|
import { getProviders } from "@/actions/providers";
|
||||||
import MainLayout from "@/components/ui/main-layout/main-layout";
|
import MainLayout from "@/components/ui/main-layout/main-layout";
|
||||||
|
import { NavigationProgress } from "@/components/ui/navigation-progress";
|
||||||
import { Toaster } from "@/components/ui/toast";
|
import { Toaster } from "@/components/ui/toast";
|
||||||
import { fontSans } from "@/config/fonts";
|
import { fontSans } from "@/config/fonts";
|
||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
@@ -38,7 +39,7 @@ export const viewport: Viewport = {
|
|||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const providersData = await getProviders({ page: 1, pageSize: 1 });
|
const providersData = await getProviders({ page: 1, pageSize: 1 });
|
||||||
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
|
const hasProviders = !!(providersData?.data && providersData.data.length > 0);
|
||||||
@@ -54,6 +55,7 @@ export default async function RootLayout({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
|
||||||
|
<NavigationProgress />
|
||||||
<StoreInitializer values={{ hasProviders }} />
|
<StoreInitializer values={{ hasProviders }} />
|
||||||
<MainLayout>{children}</MainLayout>
|
<MainLayout>{children}</MainLayout>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from "./feedback-banner/feedback-banner";
|
|||||||
export * from "./headers/navigation-header";
|
export * from "./headers/navigation-header";
|
||||||
export * from "./label/Label";
|
export * from "./label/Label";
|
||||||
export * from "./main-layout/main-layout";
|
export * from "./main-layout/main-layout";
|
||||||
|
export * from "./navigation-progress";
|
||||||
export * from "./select";
|
export * from "./select";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./toast";
|
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 */
|
||||||
--chart-dots: var(--color-neutral-200);
|
--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 ===== */
|
/* ===== DARK THEME ===== */
|
||||||
@@ -144,6 +147,9 @@
|
|||||||
|
|
||||||
/* Chart Dots */
|
/* Chart Dots */
|
||||||
--chart-dots: var(--text-neutral-primary);
|
--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 ===== */
|
/* ===== TAILWIND THEME MAPPINGS ===== */
|
||||||
@@ -211,6 +217,9 @@
|
|||||||
--color-bg-warning: var(--bg-warning-primary);
|
--color-bg-warning: var(--bg-warning-primary);
|
||||||
--color-bg-fail: var(--bg-fail-primary);
|
--color-bg-fail: var(--bg-fail-primary);
|
||||||
--color-bg-fail-secondary: var(--bg-fail-secondary);
|
--color-bg-fail-secondary: var(--bg-fail-secondary);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-progress-glow: var(--shadow-progress-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== CONTAINER UTILITY ===== */
|
/* ===== CONTAINER UTILITY ===== */
|
||||||
|
|||||||
Reference in New Issue
Block a user