refactor(ui): Replace react-router-dom navigation with custom navigation hook

This commit introduces a new custom navigation hook `useDeviceUiNavigation` to replace direct usage of `useNavigate` across multiple components:
- Removed direct `useNavigate` imports in various components
- Added `navigateTo` method from new navigation hook
- Updated navigation calls in ActionBar, MountPopover, UpdateInProgressStatusCard, and other routes
- Simplified navigation logic and prepared for potential future navigation enhancements
- Removed console logs and unnecessary comments
This commit is contained in:
Adam Shiervani 2025-02-27 12:36:08 +01:00
parent 078e719133
commit e51667e4cb
12 changed files with 105 additions and 68 deletions

View File

@ -17,13 +17,14 @@ import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
import { useNavigate } from "react-router-dom";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
@ -54,8 +55,6 @@ export default function Actionbar({
[setDisableFocusTrap],
);
const navigate = useNavigate();
return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<div
@ -269,7 +268,7 @@ export default function Actionbar({
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => navigate("settings")}
onClick={() => navigateTo("/settings")}
/>
</div>

View File

@ -13,7 +13,6 @@ const Modal = React.memo(function Modal({
open: boolean;
onClose: () => void;
}) {
console.log("Modal", open);
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop

View File

@ -2,10 +2,10 @@ import { cx } from "@/cva.config";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { useNavigate } from "react-router-dom";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function UpdateInProgressStatusCard() {
const navigate = useNavigate();
const { navigateTo } = useDeviceUiNavigation();
return (
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
@ -31,10 +31,7 @@ export default function UpdateInProgressStatusCard() {
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => {
// TODO: this wont work in cloud mode
navigate("/settings/general/update");
}}
onClick={() => navigateTo("/settings/general/update")}
/>
</div>
</GridCard>

View File

@ -16,7 +16,8 @@ import {
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import { useClose } from "@headlessui/react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
@ -187,7 +188,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, location.pathname]);
const navigate = useNavigate();
const { navigateTo } = useDeviceUiNavigation();
return (
<GridCard>
@ -307,7 +308,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
text="Add New Media"
onClick={() => {
setModalView("mode");
navigate("mount");
navigateTo("/mount");
}}
LeadingIcon={LuPlus}
/>

View File

@ -517,9 +517,7 @@ export const useUpdateStore = create<UpdateState>(set => ({
}));
interface UsbConfigModalState {
modalView:
| "updateUsbConfig"
| "updateUsbConfigSuccess";
modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
errorMessage: string | null;
setModalView: (view: UsbConfigModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
@ -548,14 +546,10 @@ interface LocalAuthModalState {
| "creationSuccess"
| "deleteSuccess"
| "updateSuccess";
errorMessage: string | null;
setModalView: (view: LocalAuthModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
}
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
}));

View File

@ -0,0 +1,58 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
import { useCallback, useMemo } from "react";
/**
* Hook that provides context-aware navigation and path generation
* that works in both cloud and device modes.
*
* In cloud mode, paths are prefixed with /devices/:id
* In device mode, paths start from the root
* Relative paths (starting with . or ..) are preserved in both modes
* Supports all React Router navigation options
*/
export function useDeviceUiNavigation() {
const navigate = useNavigate();
const params = useParams();
// Get the device ID from params
const deviceId = useMemo(() => params.id, [params.id]);
// Function to generate the correct path
const getPath = useCallback(
(path: string): string => {
// Check if it's a relative path (starts with . or ..)
const isRelativePath = path.startsWith(".") || path === "";
// If it's a relative path, don't modify it
if (isRelativePath) return path;
// Ensure absolute path starts with a slash
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (isOnDevice) {
return normalizedPath;
} else {
if (!deviceId) {
console.error("No device ID found in params when generating path");
throw new Error("No device ID found in params when generating path");
}
return `/devices/${deviceId}${normalizedPath}`;
}
},
[deviceId],
);
// Function to navigate to the correct path with all options
const navigateTo = useCallback(
(path: string, options?: NavigateOptions) => {
navigate(getPath(path), options);
},
[getPath, navigate],
);
return {
navigateTo,
getPath,
};
}

View File

@ -93,9 +93,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => {
navigate("..");
})
.then(() => navigate(".."))
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})

View File

@ -13,10 +13,11 @@ import { CLOUD_APP } from "../ui.config";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const navigate = useNavigate();
const { navigateTo } = useDeviceUiNavigation();
const [devChannel, setDevChannel] = useState(false);
const [autoUpdate, setAutoUpdate] = useState(true);
@ -135,10 +136,7 @@ export default function SettingsGeneralRoute() {
size="SM"
theme="light"
text="Check for Updates"
onClick={() => {
// TODO: this wont work in cloud mode
navigate("./update");
}}
onClick={() => navigateTo("./update")}
/>
</div>
</div>

View File

@ -7,6 +7,7 @@ import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
@ -36,15 +37,7 @@ export default function SettingsGeneralUpdateRoute() {
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return (
<Dialog
onClose={() => {
// TODO: This wont work in cloud mode
navigate("..");
}}
onConfirmUpdate={onConfirmUpdate}
/>
);
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export interface SystemVersionInfo {
@ -61,7 +54,7 @@ export function Dialog({
onClose: () => void;
onConfirmUpdate: () => void;
}) {
const navigate = useNavigate();
const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
@ -113,10 +106,7 @@ export function Dialog({
{modalView === "updating" && (
<UpdatingDeviceState
otaState={otaState}
onMinimizeUpgradeDialog={() => {
// TODO: This wont work in cloud mode
navigate("/");
}}
onMinimizeUpgradeDialog={() => navigateTo("/")}
/>
)}

View File

@ -1,10 +1,11 @@
import { SectionHeader } from "@/components/SectionHeader";
import { SettingsItem } from "./devices.$id.settings";
import { useNavigate, useLoaderData } from "react-router-dom";
import { useLoaderData } from "react-router-dom";
import { Button } from "../components/Button";
import { DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export const loader = async () => {
const status = await api
@ -15,7 +16,7 @@ export const loader = async () => {
export default function SettingsSecurityIndexRoute() {
const { authMode } = useLoaderData() as LocalDevice;
const navigate = useNavigate();
const { navigateTo } = useDeviceUiNavigation();
return (
<div className="space-y-4">
@ -35,7 +36,7 @@ export default function SettingsSecurityIndexRoute() {
theme="light"
text="Disable Protection"
onClick={() => {
navigate("local-auth", { state: { init: "deletePassword" } });
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
/>
) : (
@ -44,7 +45,7 @@ export default function SettingsSecurityIndexRoute() {
theme="light"
text="Enable Password"
onClick={() => {
navigate("local-auth", { state: { init: "createPassword" } });
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
/>
)}
@ -60,7 +61,7 @@ export default function SettingsSecurityIndexRoute() {
theme="light"
text="Change Password"
onClick={() => {
navigate("local-auth", { state: { init: "updatePassword" } });
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
/>
</SettingsItem>

View File

@ -3,32 +3,33 @@ import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation, useRevalidator } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function LocalAuthRoute() {
const navigate = useNavigate();
const { setModalView } = useLocalAuthModalStore();
const { navigateTo } = useDeviceUiNavigation();
const location = useLocation();
const init = location.state?.init;
useEffect(() => {
if (!init) {
navigate("..");
navigateTo("..");
} else {
setModalView(init);
}
}, [init, navigate, setModalView]);
}, [init, navigateTo, setModalView]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} />;
return <Dialog onClose={() => navigateTo("..")} />;
}
export function Dialog({ onClose }: { onClose: () => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator();
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
@ -45,6 +46,8 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
@ -82,6 +85,8 @@ export function Dialog({ onClose }: { onClose: () => void }) {
if (res.ok) {
setModalView("updateSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
@ -101,6 +106,8 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) {
setModalView("deleteSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");

View File

@ -38,6 +38,7 @@ import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
@ -322,11 +323,11 @@ export default function KvmIdRoute() {
const setHdmiState = useVideoStore(state => state.setHdmiState);
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
function onJsonRpcRequest(resp: JsonRpcRequest) {
if (resp.method === "otherSessionConnected") {
console.log("otherSessionConnected", resp.params);
navigate("other-session");
navigateTo("/other-session");
}
if (resp.method === "usbState") {
@ -350,8 +351,7 @@ export default function KvmIdRoute() {
if (otaState.error) {
setModalView("error");
// TODO: this wont work in cloud mode
navigate("update");
navigateTo("/settings/general/update");
return;
}
@ -381,12 +381,9 @@ export default function KvmIdRoute() {
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
if (queryParams.get("updateSuccess")) {
// TODO: this wont work in cloud mode
navigate("./settings/general/update", {
state: { updateSuccess: true },
});
navigateTo("/settings/general/update", { state: { updateSuccess: true } });
}
}, [navigate, queryParams, setModalView, setQueryParams]);
}, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
const diskChannel = useRTCStore(state => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!;
@ -442,10 +439,8 @@ export default function KvmIdRoute() {
const outlet = useOutlet();
const location = useLocation();
const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") {
navigate("..");
}
}, [navigate, location.pathname]);
if (location.pathname !== "/other-session") navigateTo("..");
}, [navigateTo, location.pathname]);
return (
<>
@ -497,7 +492,7 @@ export default function KvmIdRoute() {
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Escape") navigate("./");
if (e.key === "Escape") navigateTo("/");
}}
>
<Modal open={outlet !== null} onClose={onModalClose}>