diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
index 0782c1f94..e951a4318 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
@@ -1,6 +1,8 @@
"use client";
-import { useCallback } from "react";
+import type { MouseEvent } from "react";
+import { useCallback, useEffect } from "react";
+import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
@@ -16,7 +18,7 @@ import {
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
-import { useModalAction } from "@homarr/modals";
+import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -26,6 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
+import { env } from "~/env.mjs";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
@@ -139,6 +142,7 @@ const EditModeMenu = () => {
}, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]);
+ usePreventLeaveWithDirty(isEditMode);
return (
@@ -146,3 +150,63 @@ const EditModeMenu = () => {
);
};
+
+const usePreventLeaveWithDirty = (isDirty: boolean) => {
+ const t = useI18n();
+ const { openConfirmModal } = useConfirmModal();
+ const router = useRouter();
+
+ useEffect(() => {
+ const handleClick = (event: MouseEvent) => {
+ const target = (event.target as HTMLElement).closest("a");
+
+ if (!target) return;
+ if (!isDirty) return;
+
+ event.preventDefault();
+
+ openConfirmModal({
+ title: t("board.action.edit.confirmLeave.title"),
+ children: t("board.action.edit.confirmLeave.message"),
+ onConfirm() {
+ router.push(target.href);
+ },
+ confirmProps: {
+ children: t("common.action.discard"),
+ },
+ });
+ };
+
+ const handlePopState = (event: Event) => {
+ if (isDirty) {
+ window.history.pushState(null, document.title, window.location.href);
+ event.preventDefault();
+ } else {
+ window.history.back();
+ }
+ };
+
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!isDirty) return;
+ if (env.NODE_ENV === "development") return; // Allow to reload in development
+
+ event.preventDefault();
+ event.returnValue = true;
+ };
+
+ document.querySelectorAll("a").forEach((link) => {
+ link.addEventListener("click", handleClick as never);
+ });
+ window.addEventListener("popstate", handlePopState);
+ window.addEventListener("beforeunload", handleBeforeUnload);
+
+ return () => {
+ document.querySelectorAll("a").forEach((link) => {
+ link.removeEventListener("click", handleClick as never);
+ window.removeEventListener("popstate", handlePopState);
+ });
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isDirty]);
+};
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index 63b089883..503642482 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -61,7 +61,8 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
}, [logoutUrl, openModal, router]);
return (
-