From a5b7fb763edb6659881b41bb4182dc563accd68d Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 5 Mar 2026 18:03:37 -0500 Subject: [PATCH] Add navigation progress bar for instant page transition feedback Context-based provider with useProgress() hook that auto-detects link clicks and popstate, with manual start()/done()/set() for router.push() calls. Uses useEffectEvent for clean effect deps. Co-Authored-By: Claude Opus 4.6 --- app/(pages)/layout.tsx | 25 ++- .../_components/title-keyboard-shortcuts.tsx | 11 +- components/command-palette.tsx | 31 ++- components/navigation-progress.tsx | 195 ++++++++++++++++++ 4 files changed, 241 insertions(+), 21 deletions(-) create mode 100644 components/navigation-progress.tsx diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index 47737b9..86fdad9 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { CommandPalette } from "@/components/command-palette"; import { MobileTabBar } from "@/components/mobile-tab-bar"; import { NavBar } from "@/components/nav-bar"; +import { ProgressProvider } from "@/components/navigation-progress"; import { UpdateToast } from "@/components/update-toast"; import { getSession } from "@/lib/auth/session"; @@ -16,17 +17,19 @@ export default async function PagesLayout({ return ( -
- - {/* Ambient glow */} -
-
- {children} -
-
- - - + +
+ + {/* Ambient glow */} +
+
+ {children} +
+
+ + + + ); } diff --git a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx index d239857..f3d7549 100644 --- a/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx +++ b/app/(pages)/titles/[id]/_components/title-keyboard-shortcuts.tsx @@ -4,12 +4,14 @@ import type { Hotkey } from "@tanstack/react-hotkeys"; import { useHotkey } from "@tanstack/react-hotkeys"; import { useAtomValue } from "jotai"; import { useRouter } from "next/navigation"; +import { useProgress } from "@/components/navigation-progress"; import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette"; import { titleTypeAtom, userStatusAtom } from "@/lib/atoms/title"; import { useTitleActions } from "./use-title-actions"; export function TitleKeyboardShortcuts() { const router = useRouter(); + const progress = useProgress(); const titleType = useAtomValue(titleTypeAtom); const userStatus = useAtomValue(userStatusAtom); const { handleStatusChange, handleRating, handleWatchMovie } = @@ -29,7 +31,14 @@ export function TitleKeyboardShortcuts() { }, { enabled }, ); - useHotkey("Escape", () => router.back(), { enabled }); + useHotkey( + "Escape", + () => { + progress.start(); + router.back(); + }, + { enabled }, + ); for (const n of [1, 2, 3, 4, 5]) { // biome-ignore lint/correctness/useHookAtTopLevel: loop is stable (always 5 iterations) diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 0a0f700..cc15a9a 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -14,6 +14,7 @@ import { useAtom } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import { useProgress } from "@/components/navigation-progress"; import { Command, CommandEmpty, @@ -47,6 +48,7 @@ type SearchResult = ReturnType["results"][number]; export function CommandPalette() { const router = useRouter(); + const progress = useProgress(); const [commandPaletteOpen, setCommandPaletteOpen] = useAtom( commandPaletteOpenAtom, ); @@ -60,14 +62,22 @@ export function CommandPalette() { useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev)); useHotkey("/", () => setCommandPaletteOpen(true), { enabled }); useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled }); - useHotkeySequence(["G", "H"], () => router.push("/dashboard"), { - enabled, - timeout: 500, - }); - useHotkeySequence(["G", "E"], () => router.push("/explore"), { - enabled, - timeout: 500, - }); + useHotkeySequence( + ["G", "H"], + () => { + progress.start(); + router.push("/dashboard"); + }, + { enabled, timeout: 500 }, + ); + useHotkeySequence( + ["G", "E"], + () => { + progress.start(); + router.push("/explore"); + }, + { enabled, timeout: 500 }, + ); // Reset query when palette opens useEffect(() => { @@ -90,13 +100,14 @@ export function CommandPalette() { const handleSelect = useCallback( (result: SearchResult) => { setCommandPaletteOpen(false); + progress.start(); if (result.type === "person") { router.push(`/people/tmdb-${result.tmdbId}`); } else { router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); } }, - [router, setCommandPaletteOpen], + [router, setCommandPaletteOpen, progress], ); const handleRecentSearch = useCallback((q: string) => { @@ -285,6 +296,7 @@ export function CommandPalette() { { setCommandPaletteOpen(false); + progress.start(); router.push("/dashboard"); }} > @@ -295,6 +307,7 @@ export function CommandPalette() { { setCommandPaletteOpen(false); + progress.start(); router.push("/explore"); }} > diff --git a/components/navigation-progress.tsx b/components/navigation-progress.tsx new file mode 100644 index 0000000..24a5058 --- /dev/null +++ b/components/navigation-progress.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from "react"; + +type ProgressApi = { + start: () => void; + done: () => void; + set: (pct: number) => void; +}; + +const ProgressContext = createContext(null); + +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} + +export function ProgressProvider({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [visible, setVisible] = useState(false); + const [progress, setProgress] = useState(0); + + const inFlightRef = useRef(false); + const showTimerRef = useRef>(undefined); + const trickleTimerRef = useRef>(undefined); + const finishTimerRef = useRef>(undefined); + const safetyTimerRef = useRef>(undefined); + + function clearTimers() { + clearTimeout(showTimerRef.current); + clearInterval(trickleTimerRef.current); + clearTimeout(finishTimerRef.current); + clearTimeout(safetyTimerRef.current); + } + + function start() { + if (inFlightRef.current) return; + inFlightRef.current = true; + clearTimers(); + + // Delay showing to avoid flash on instant (prefetched) navigations + showTimerRef.current = setTimeout(() => { + setVisible(true); + setProgress(8); + + trickleTimerRef.current = setInterval(() => { + setProgress((p) => { + if (!inFlightRef.current) return p; + return clamp(p + Math.max(0.5, (90 - p) * 0.08), 0, 90); + }); + }, 200); + }, 100); + + // Safety timeout to prevent stuck bar + safetyTimerRef.current = setTimeout(() => { + inFlightRef.current = false; + clearTimers(); + setVisible(false); + setProgress(0); + }, 12000); + } + + function done() { + if (!inFlightRef.current) return; + inFlightRef.current = false; + clearTimers(); + + setProgress(100); + setVisible(true); + + finishTimerRef.current = setTimeout(() => { + setVisible(false); + setTimeout(() => setProgress(0), 200); + }, 200); + } + + function set(pct: number) { + const next = clamp(pct, 0, 100); + if (next >= 100) { + done(); + return; + } + if (!inFlightRef.current) start(); + setProgress(next); + } + + // Effect events — always see latest closures, don't appear in deps + const onLinkClick = useEffectEvent((e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0) return; + if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return; + + const anchor = (e.target as Element)?.closest?.("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) return; + if (anchor.target && anchor.target !== "_self") return; + if (anchor.hasAttribute("download")) return; + + const href = anchor.getAttribute("href"); + if (!href || href.startsWith("#")) return; + + try { + const url = new URL(href, window.location.href); + if (url.origin !== window.location.origin) return; + } catch { + return; + } + + start(); + }); + + const onPopState = useEffectEvent(() => start()); + + const onRouteChange = useEffectEvent(() => { + if (inFlightRef.current) done(); + }); + + // Set up listeners once + useEffect(() => { + document.addEventListener("click", onLinkClick, true); + window.addEventListener("popstate", onPopState); + return () => { + document.removeEventListener("click", onLinkClick, true); + window.removeEventListener("popstate", onPopState); + }; + }, []); + + // Finish on route change — routeKey is intentionally a dep to trigger on navigation + const routeKey = pathname + (searchParams?.toString() ?? ""); + const firstRenderRef = useRef(true); + // biome-ignore lint/correctness/useExhaustiveDependencies: routeKey drives re-runs on route change + useEffect(() => { + if (firstRenderRef.current) { + firstRenderRef.current = false; + return; + } + onRouteChange(); + }, [routeKey]); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + clearTimeout(showTimerRef.current); + clearInterval(trickleTimerRef.current); + clearTimeout(finishTimerRef.current); + clearTimeout(safetyTimerRef.current); + }; + }, []); + + // Stable context API via ref indirection + const apiRef = useRef({ start, done, set }); + apiRef.current = { start, done, set }; + + const api = useMemo( + () => ({ + start: () => apiRef.current.start(), + done: () => apiRef.current.done(), + set: (pct) => apiRef.current.set(pct), + }), + [], + ); + + return ( + +