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 (
+
+
+ {children}
+
+ );
+}
+
+export function useProgress(): ProgressApi {
+ const ctx = useContext(ProgressContext);
+ if (!ctx) {
+ throw new Error("useProgress must be used inside .");
+ }
+ return ctx;
+}