From 6f62f3ca434d6f59706dc4db283fd7f4480629bd Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 8 Sep 2026 18:07:22 +0200 Subject: [PATCH] feat: add board editing shortcuts to user and context menus --- apps/docs/docs/management/boards/index.mdx | 7 +- .../app/[locale]/boards/(content)/_client.tsx | 145 +++++----- .../[locale]/boards/(content)/_creator.tsx | 2 +- .../boards/(content)/_edit-actions.tsx | 59 +---- .../boards/(content)/_editing-provider.tsx | 247 ++++++++++++++++++ .../boards/(content)/_header-actions.tsx | 223 ++-------------- .../app/[locale]/boards/_layout-creator.tsx | 34 ++- .../components/board/board-add-menu-items.tsx | 37 +++ .../components/board/board-context-menu.tsx | 68 +++++ .../src/components/user-avatar-menu.tsx | 16 ++ 10 files changed, 490 insertions(+), 348 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_editing-provider.tsx create mode 100644 apps/nextjs/src/components/board/board-add-menu-items.tsx create mode 100644 apps/nextjs/src/components/board/board-context-menu.tsx diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index d81c47290..18df5180c 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -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. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 670b908cc..09dabd84b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -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 = ( - - - - - - - - - -
- {leftColumnCount > 0 && leftSection && ( - - )} + + + + + + + + + + +
+ {leftColumnCount > 0 && leftSection && ( + + )} -
- -
- - {rightColumnCount > 0 && rightSection && ( - - )} -
-
-
-
-
- - , variant: "light" }} - > - {tTips("multiSelectApps")} - -
+ + + {rightColumnCount > 0 && rightSection && ( + + )} +
+
+
+
+
+ + , variant: "light" }} + > + {tTips("multiSelectApps")} + +
+
); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index ae4f66802..19c5c62b7 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -36,7 +36,7 @@ export const createBoardContentPage = >( }: Props) => { return { layout: createBoardLayout({ - headerBoardEditAction: , + headerBoardEditAction: , headerBoardSettingsAction: , getInitialBoardAsync: getInitialBoard, withTour: true, diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx index 29dbc8ac0..ad4046c8d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx @@ -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 ( @@ -87,21 +48,7 @@ const AddMenu = () => { - } onClick={handleSelectItem}> - {tBoard("emptyState.addWidget")} - - } onClick={handleSelectApp}> - {tBoard("emptyState.addApp")} - - {session?.user.permissions.includes("integration-create") && ( - } onClick={handleAddIntegration}> - {tIntegration("action.create")} - - )} - - } onClick={addContainer}> - {tContainer("action.create")} - + ); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_editing-provider.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_editing-provider.tsx new file mode 100644 index 000000000..26b1231f2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_editing-provider.tsx @@ -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 | 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 | null>(null); +export const useOptionalBoardEditing = () => useContext(BoardEditingContext); + +export const BoardEditingProvider = ({ + children, + enabled, + demoReadOnly, +}: PropsWithChildren<{ enabled: boolean; demoReadOnly: boolean }>) => { + if (!enabled) return children; + return {children}; +}; +const ActiveBoardEditingProvider = ({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) => { + const controller = useBoardEditingController(demoReadOnly); + return {children}; +}; +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(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]); +}; 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 41552ff34..fa0365e2a 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -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 | 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 && } - + ); }; @@ -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 ( <> 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 ? : } @@ -225,57 +94,3 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { ); }; - -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(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]); -}; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 69b39a38a..c6b4e0c11 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -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 = ({ - - - - } - logoWithTitle={} - actions={headerActions} - boardEditAction={headerBoardEditAction} - boardSettingsAction={headerBoardSettingsAction} - hasNavigation={false} - /> - {children} - - + + + + + } + logoWithTitle={} + actions={headerActions} + boardEditAction={headerBoardEditAction} + boardSettingsAction={headerBoardSettingsAction} + hasNavigation={false} + /> + {children} + + + diff --git a/apps/nextjs/src/components/board/board-add-menu-items.tsx b/apps/nextjs/src/components/board/board-add-menu-items.tsx new file mode 100644 index 000000000..85b6f1bfe --- /dev/null +++ b/apps/nextjs/src/components/board/board-add-menu-items.tsx @@ -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 ( + <> + } onClick={() => void run(addWidget)}> + {tBoard("emptyState.addWidget")} + + } onClick={() => void run(addApp)}> + {tBoard("emptyState.addApp")} + + {canConnectService && ( + } onClick={() => void run(connectService)}> + {tIntegration("action.create")} + + )} + + } onClick={() => void run(addContainer)}> + {tContainer("action.create")} + + + ); +}; diff --git a/apps/nextjs/src/components/board/board-context-menu.tsx b/apps/nextjs/src/components/board/board-context-menu.tsx new file mode 100644 index 000000000..a8f8cbd57 --- /dev/null +++ b/apps/nextjs/src/components/board/board-context-menu.tsx @@ -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) => { + 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 ( + + {children} + { + if (!opened) setPosition(null); + }} + position="bottom-start" + offset={2} + withinPortal + withArrow={false} + shadow="md" + > + + + + {position && } + + + ); +}; diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 903a7064f..6d0a62f41 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -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")} )} + {editing?.hasChangeAccess && ( + : } + rightSection={{formatHotkeyLabel(hotkeys.toggleBoardEdit, t("modifier"))}} + disabled={editing.isPending || editing.isEnteringEditMode} + onClick={() => void editing.toggle()} + onFocus={editing.prewarmEditor} + onPointerEnter={editing.prewarmEditor} + > + {editing.label} + + )} } rightSection={{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}}