feat: add board editing shortcuts to user and context menus

This commit is contained in:
ajnart committed 2026-09-08 18:07:22 +02:00
1 parent f23d197a35
commit 6f62f3ca43
10 files changed
+490 -348

No files matched your search

+5 -2
View File
@@ -46,8 +46,8 @@ scrolls and store their contents separately for each layout.
## Edit a board
Enter edit mode from the board header to add, move, resize, configure, or remove content. Save the board to persist the
layout.
Enter edit mode from the board header or account menu to add, move, resize, configure, or remove content. The account
menu also works when the header is hidden, using the floating user button. Choose **Save board** to persist changes.
- Drag from an item's inactive content area; Containers use their grip.
- Hold `Ctrl` on Windows/Linux or `Cmd` on macOS to select several items.
@@ -56,6 +56,9 @@ layout.
- Press Escape during a pointer or touch interaction to restore the item to its previous position.
The add-content menu can create an app, integration, widget, or Container without leaving the board.
Right-click empty board space to open this menu at the cursor. Selecting an action enters edit mode automatically.
These actions require board editing permission; creating integrations also requires integration creation permission.
Right-clicking existing content keeps its own menu. Hold Shift while right-clicking to use the browser menu.
When adding apps, click one app to add it immediately, or choose **Select multiple** to add several at once. `Ctrl`/`Cmd`
click remains available as a shortcut.
@@ -13,6 +13,7 @@ import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardContextMenu } from "~/components/board/board-context-menu";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
@@ -68,81 +69,83 @@ export const ClientBoard = () => {
const content = (
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
scaling={getBoardScaling(board)}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<BoardContextMenu>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
scaling={getBoardScaling(board)}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardContextMenu>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
);
@@ -36,7 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
@@ -3,18 +3,14 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { BoardAddMenuItems } from "~/components/board/board-add-menu-items";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -22,16 +18,9 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -48,34 +37,6 @@ const AddMenu = () => {
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
onSelectMany: (apps) => {
for (const app of apps) {
createItem({
kind: "app",
options: { appId: app.id },
});
}
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({ completionBoardId: board.id });
};
return (
<Menu position="bottom-end">
<Menu.Target>
@@ -87,21 +48,7 @@ const AddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{tBoard("emptyState.addApp")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{tContainer("action.create")}
</Menu.Item>
<BoardAddMenuItems />
</Menu.Dropdown>
</Menu>
);
@@ -0,0 +1,247 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import type * as EditActionsModule from "./_edit-actions";
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
const BoardEditingContext = createContext<ReturnType<typeof useBoardEditingController> | null>(null);
export const useOptionalBoardEditing = () => useContext(BoardEditingContext);
export const BoardEditingProvider = ({
children,
enabled,
demoReadOnly,
}: PropsWithChildren<{ enabled: boolean; demoReadOnly: boolean }>) => {
if (!enabled) return children;
return <ActiveBoardEditingProvider demoReadOnly={demoReadOnly}>{children}</ActiveBoardEditingProvider>;
};
const ActiveBoardEditingProvider = ({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) => {
const controller = useBoardEditingController(demoReadOnly);
return <BoardEditingContext.Provider value={controller}>{children}</BoardEditingContext.Provider>;
};
const useBoardEditingController = (demoReadOnly: boolean) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const utils = clientApi.useUtils();
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
searchParams.get("layout") !== null &&
searchParams.get("returnTo") === "settings";
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
},
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const enter = useCallback(async () => {
if (!hasChangeAccess || isPending || isEnteringEditMode) return false;
if (isEditMode) return true;
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
open();
return true;
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
return false;
} finally {
setIsEnteringEditMode(false);
}
}, [hasChangeAccess, isPending, isEnteringEditMode, isEditMode, open, prepareEditorAsync, t]);
const toggle = useCallback(async () => {
if (!hasChangeAccess || isPending || isEnteringEditMode) return;
if (!isEditMode) return enter();
if (demoReadOnly) return discardDemoChanges();
saveBoard(latestBoardRef.current);
}, [hasChangeAccess, isPending, isEnteringEditMode, isEditMode, enter, demoReadOnly, discardDemoChanges, saveBoard]);
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
close();
router.push(`/boards/${board.name}/settings?tab=layout`);
})();
},
});
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode && hasChangeAccess);
let label = t("enter");
if (isEditMode) label = t("save");
if (isEditMode && demoReadOnly) label = t("exit");
return {
isEditMode,
hasChangeAccess,
label,
toggle,
enter,
prewarmEditor,
editorLoadState,
isPending,
isEnteringEditMode,
returnToLayoutSettings,
cancelLayoutEdit,
};
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
if (!isDirty) return;
const handleClick = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return;
event.preventDefault();
event.stopPropagation();
openConfirmModal({
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: tCommon("action.discard"),
},
});
};
const handlePopState = (event: Event) => {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
event.preventDefault();
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -1,45 +1,15 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { useOptionalBoardEditing } from "./_editing-provider";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
import type * as EditActionsModule from "./_edit-actions";
let editActionsModulePromise: Promise<typeof EditActionsModule> | undefined;
const loadEditActionsAsync = () => {
editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => {
editActionsModulePromise = undefined;
throw error;
});
return editActionsModulePromise;
};
const loadBoardEditorAsync = async () =>
await Promise.all([
loadGridEditorAsync(),
loadEditActionsAsync(),
import("~/components/board/items/item-menu"),
import("~/components/board/sections/container/container-menu"),
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const BoardEditActions = dynamic(() => import("./_edit-actions"), { ssr: false });
export const BoardContentEditAction = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
@@ -49,7 +19,7 @@ export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
<EditModeMenu />
</>
);
};
@@ -70,127 +40,26 @@ export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boo
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const EditModeMenu = () => {
const editing = useOptionalBoardEditing();
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
searchParams.get("layout") !== null &&
searchParams.get("returnTo") === "settings";
const latestBoardRef = useRef(board);
latestBoardRef.current = board;
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onMutate(boardToSave) {
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
},
onSuccess() {
showSuccessNotification({
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
},
onError() {
showErrorNotification({
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
});
const discardDemoChanges = useCallback(() => {
void utils.board.getBoardByName.invalidate({ name: board.name });
close();
}, [utils, board.name, close]);
const prepareEditorAsync = useCallback(async () => {
if (editorLoadState === "ready") return;
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
throw new Error("Unable to load the board editor");
}
}, [editorLoadState]);
useEffect(() => {
return scheduleGridEditorWarmup(async () => {
setEditorLoadState("loading");
try {
await loadBoardEditorAsync();
setEditorLoadState("ready");
} catch {
setEditorLoadState("error");
}
});
}, []);
const prewarmEditor = useCallback(() => {
void prepareEditorAsync().catch(() => {
// A click or hotkey retries and reports the error.
});
}, [prepareEditorAsync]);
const toggle = useCallback(async () => {
if (isEditMode) {
if (demoReadOnly) return discardDemoChanges();
return saveBoard(latestBoardRef.current);
}
setIsEnteringEditMode(true);
try {
await prepareEditorAsync();
open();
} catch {
showErrorNotification({
title: t("notification.loadError.title"),
message: t("notification.loadError.message"),
});
} finally {
setIsEnteringEditMode(false);
}
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
close();
router.push(`/boards/${board.name}/settings?tab=layout`);
})();
},
});
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
if (!editing) return null;
const {
isEditMode,
toggle,
prewarmEditor,
editorLoadState,
isPending,
isEnteringEditMode,
returnToLayoutSettings,
cancelLayoutEdit,
} = editing;
if (returnToLayoutSettings) {
return (
<>
<HeaderButton
onClick={() => saveBoard(board)}
onClick={() => void toggle()}
loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")}
>
@@ -217,7 +86,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
data-testid="board-edit-mode-toggle"
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-label={editing.label}
aria-pressed={isEditMode}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
@@ -225,57 +94,3 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
useEffect(() => {
if (!isDirty) return;
const handleClick = (event: MouseEvent) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
if (!target) return;
event.preventDefault();
event.stopPropagation();
openConfirmModal({
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: tCommon("action.discard"),
},
});
};
const handlePopState = (event: Event) => {
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
event.preventDefault();
event.returnValue = true;
};
document.addEventListener("click", handleClick, true);
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -20,6 +20,7 @@ import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProviders } from "./_providers";
import { BoardEditingProvider } from "./(content)/_editing-provider";
import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
@@ -106,20 +107,25 @@ export const createBoardLayout = <TParams extends Params>({
<BoardReadyProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<ModalProvider>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
<BoardEditingProvider
enabled={Boolean(headerBoardEditAction) && hasChangeAccess}
demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY}
>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</BoardEditingProvider>
</ModalProvider>
</BoardMantineProvider>
</BoardReadyProvider>
@@ -0,0 +1,37 @@
"use client";
import { Menu } from "@mantine/core";
import { IconBox, IconLayoutGridAdd, IconPlug, IconResize } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "./use-board-add-actions";
import { useOptionalBoardEditing } from "~/app/[locale]/boards/(content)/_editing-provider";
export const BoardAddMenuItems = () => {
const editing = useOptionalBoardEditing();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const run = async (action: () => void) => {
if (await editing?.enter()) action();
};
return (
<>
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => void run(addWidget)}>
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={() => void run(addApp)}>
{tBoard("emptyState.addApp")}
</Menu.Item>
{canConnectService && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => void run(connectService)}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={() => void run(addContainer)}>
{tContainer("action.create")}
</Menu.Item>
</>
);
};
@@ -0,0 +1,68 @@
"use client";
import type { PropsWithChildren, MouseEvent } from "react";
import { useEffect, useState } from "react";
import { Box, Menu } from "@mantine/core";
import dynamic from "next/dynamic";
import { useOptionalBoardEditing } from "~/app/[locale]/boards/(content)/_editing-provider";
const BoardAddMenuItems = dynamic(() => import("./board-add-menu-items").then((module) => module.BoardAddMenuItems));
const occupiedSpaceSelector =
'[data-grid-item-id], [data-editor-grid-entry], [data-item-id], button, a, input, textarea, select, [contenteditable], [role="button"], [role="menu"], [role="dialog"]';
export const BoardContextMenu = ({ children }: PropsWithChildren) => {
const editing = useOptionalBoardEditing();
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!position) return;
const close = () => setPosition(null);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [position]);
const openMenu = (event: MouseEvent<HTMLDivElement>) => {
if (!editing?.hasChangeAccess || editing.isPending || event.defaultPrevented || event.shiftKey) return;
if (!(event.target instanceof Element) || event.target.closest(occupiedSpaceSelector)) return;
if (window.getSelection()?.toString()) return;
event.preventDefault();
event.stopPropagation();
setPosition({ x: event.clientX, y: event.clientY });
};
return (
<Box h="100%" onContextMenu={openMenu}>
{children}
<Menu
opened={position !== null && Boolean(editing?.hasChangeAccess)}
onChange={(opened) => {
if (!opened) setPosition(null);
}}
position="bottom-start"
offset={2}
withinPortal
withArrow={false}
shadow="md"
>
<Menu.Target>
<span
aria-hidden
style={{
position: "fixed",
left: position?.x ?? 0,
top: position?.y ?? 0,
width: 0,
height: 0,
pointerEvents: "none",
}}
/>
</Menu.Target>
<Menu.Dropdown>{position && <BoardAddMenuItems />}</Menu.Dropdown>
</Menu>
</Box>
);
};
@@ -13,6 +13,8 @@ import {
IconSettings,
IconRobot,
IconTool,
IconPencil,
IconDeviceFloppy,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -25,6 +27,7 @@ import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalBoardEditing } from "~/app/[locale]/boards/(content)/_editing-provider";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import type { BoardSwitcherControls } from "./board/board-switcher";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
@@ -50,6 +53,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
const tBoard = useI18n("board");
const session = useSession();
const board = useOptionalBoard();
const editing = useOptionalBoardEditing();
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
const { logoutUrl } = useAuthContext();
@@ -81,6 +85,18 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
{tBoard("action.settings")}
</Menu.Item>
)}
{editing?.hasChangeAccess && (
<Menu.Item
leftSection={editing.isEditMode ? <IconDeviceFloppy size="1rem" /> : <IconPencil size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkeys.toggleBoardEdit, t("modifier"))}</Kbd>}
disabled={editing.isPending || editing.isEnteringEditMode}
onClick={() => void editing.toggle()}
onFocus={editing.prewarmEditor}
onPointerEnter={editing.prewarmEditor}
>
{editing.label}
</Menu.Item>
)}
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}