diff --git a/apps/nextjs/src/app/[locale]/boards/_board-hotkeys.tsx b/apps/nextjs/src/app/[locale]/boards/_board-hotkeys.tsx new file mode 100644 index 000000000..ec8ae69a6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_board-hotkeys.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useBoardHotkeys } from "~/components/board/use-board-hotkeys"; + +export const BoardHotkeys = () => { + useBoardHotkeys(); + return null; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 3cddc6685..b22e45065 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -18,6 +18,7 @@ import type { Params } from "./(content)/_creator"; import { CustomCss } from "./(content)/_custom-css"; import { BoardReadyProvider } from "./(content)/_ready-context"; import { BoardMantineProvider } from "./(content)/_theme"; +import { BoardHotkeys } from "./_board-hotkeys"; const logger = createLogger({ module: "createBoardLayout" }); @@ -69,6 +70,7 @@ export const createBoardLayout = ({ + {children}; if (!isRightClickEnabled) return <>{children}; + if (item.kind === "app") return <>{children}; const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index b5e468de6..18a5a1dc0 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -319,9 +319,24 @@ const useCssVariableConfiguration = ({ columnCount, isDynamic, }: UseCssVariableConfiguration) => { + const prevWidthRef = useRef(null); + const onResize = useCallback(() => { if (!wrapperRef.current) return; if (!gridRef.current) return; + + const currentWidth = wrapperRef.current.clientWidth; + const prevWidth = prevWidthRef.current; + + // Only recalculate if the width change is significant enough (> 20px) + // This prevents infinite feedback loops when the vertical scrollbar + // appears/disappears, causing clientWidth to oscillate by ~15px + if (prevWidth !== null && Math.abs(currentWidth - prevWidth) < 20) { + return; + } + + prevWidthRef.current = currentWidth; + handleResizeChange( wrapperRef.current, gridRef.current, diff --git a/apps/nextjs/src/components/board/use-board-hotkeys.ts b/apps/nextjs/src/components/board/use-board-hotkeys.ts new file mode 100644 index 000000000..225fd87b6 --- /dev/null +++ b/apps/nextjs/src/components/board/use-board-hotkeys.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { hotkeys } from "@homarr/definitions"; +import { useHotkeys } from "@mantine/hooks"; + +export const useBoardHotkeys = () => { + const router = useRouter(); + const currentBoard = useRequiredBoard(); + const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + + const navigateToBoard = useCallback( + (boardName: string) => { + if (boardName !== currentBoard.name) { + router.push(`/boards/${boardName}`); + } + }, + [router, currentBoard.name], + ); + + const currentIndex = boards.findIndex((board) => board.id === currentBoard.id); + + useHotkeys([ + [hotkeys.nextBoard, () => navigateToNextBoard(boards, currentIndex, navigateToBoard)], + [hotkeys.previousBoard, () => navigateToPreviousBoard(boards, currentIndex, navigateToBoard)], + [hotkeys.switchBoard1, () => navigateToBoardByIndex(boards, 0, navigateToBoard)], + [hotkeys.switchBoard2, () => navigateToBoardByIndex(boards, 1, navigateToBoard)], + [hotkeys.switchBoard3, () => navigateToBoardByIndex(boards, 2, navigateToBoard)], + [hotkeys.switchBoard4, () => navigateToBoardByIndex(boards, 3, navigateToBoard)], + [hotkeys.switchBoard5, () => navigateToBoardByIndex(boards, 4, navigateToBoard)], + [hotkeys.switchBoard6, () => navigateToBoardByIndex(boards, 5, navigateToBoard)], + [hotkeys.switchBoard7, () => navigateToBoardByIndex(boards, 6, navigateToBoard)], + [hotkeys.switchBoard8, () => navigateToBoardByIndex(boards, 7, navigateToBoard)], + [hotkeys.switchBoard9, () => navigateToBoardByIndex(boards, 8, navigateToBoard)], + ]); +}; + +const navigateToNextBoard = ( + boards: { name: string }[], + currentIndex: number, + navigate: (name: string) => void, +) => { + if (boards.length === 0) return; + const nextIndex = (currentIndex + 1) % boards.length; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + navigate(boards[nextIndex]!.name); +}; + +const navigateToPreviousBoard = ( + boards: { name: string }[], + currentIndex: number, + navigate: (name: string) => void, +) => { + if (boards.length === 0) return; + const previousIndex = (currentIndex - 1 + boards.length) % boards.length; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + navigate(boards[previousIndex]!.name); +}; + +const navigateToBoardByIndex = ( + boards: { name: string }[], + index: number, + navigate: (name: string) => void, +) => { + if (index < boards.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + navigate(boards[index]!.name); + } +}; diff --git a/packages/definitions/src/hotkeys.ts b/packages/definitions/src/hotkeys.ts index b3a8cab42..967f64412 100644 --- a/packages/definitions/src/hotkeys.ts +++ b/packages/definitions/src/hotkeys.ts @@ -3,4 +3,15 @@ export const hotkeys = { toggleColorScheme: "mod+j", saveNotebook: "mod+s", openSpotlight: "mod+k", + nextBoard: "mod+shift+ArrowRight", + previousBoard: "mod+shift+ArrowLeft", + switchBoard1: "mod+shift+1", + switchBoard2: "mod+shift+2", + switchBoard3: "mod+shift+3", + switchBoard4: "mod+shift+4", + switchBoard5: "mod+shift+5", + switchBoard6: "mod+shift+6", + switchBoard7: "mod+shift+7", + switchBoard8: "mod+shift+8", + switchBoard9: "mod+shift+9", }; diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index a9599e216..34b087107 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -1,4 +1,4 @@ -import { ResponseError } from "@homarr/common/server"; +import { ParseError, ResponseError } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; @@ -163,7 +163,18 @@ export class BeszelIntegration extends Integration { throw new ResponseError(response); } - const data = (await response.json()) as BeszelAuthResponse; + let data: BeszelAuthResponse; + try { + data = (await response.json()) as BeszelAuthResponse; + } catch (error) { + logger.warn("Failed to parse Beszel auth response JSON", { + integrationId: this.integration.id, + error: error instanceof Error ? error.message : String(error), + }); + throw new ErrorWithMetadata("Beszel authentication response was not valid JSON", { + integrationId: this.integration.id, + }); + } const expiresAt = parseTokenExpiration(data.token) ?? undefined; // A token that has already lapsed by the time it reaches us means our clock and Beszel's @@ -608,7 +619,15 @@ export class BeszelIntegration extends Integration { return TestConnectionError.StatusResult({ status: response.status, url: response.url }); } - const data = (await response.json()) as BeszelAuthResponse; + let data: BeszelAuthResponse; + try { + data = (await response.json()) as BeszelAuthResponse; + } catch (error) { + logger.warn("Failed to parse Beszel testing response JSON", { + error: error instanceof Error ? error.message : String(error), + }); + return TestConnectionError.ParseResult(new ParseError("Beszel testing response was not valid JSON")); + } if (!data.token) { return TestConnectionError.UnauthorizedResult(401); } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0c7b91dd9..e16040d9b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5272,7 +5272,18 @@ "toggleBoardEdit": "Toggle board edit mode", "toggleColorScheme": "Toggle light/dark mode", "saveNotebook": "Save notebook (only inside notebook widget)", - "openSpotlight": "Open search" + "openSpotlight": "Open search", + "nextBoard": "Switch to next board", + "previousBoard": "Switch to previous board", + "switchBoard1": "Switch to first board", + "switchBoard2": "Switch to second board", + "switchBoard3": "Switch to third board", + "switchBoard4": "Switch to fourth board", + "switchBoard5": "Switch to fifth board", + "switchBoard6": "Switch to sixth board", + "switchBoard7": "Switch to seventh board", + "switchBoard8": "Switch to eighth board", + "switchBoard9": "Switch to ninth board" }, "note": "Tip: Mod refers to both Ctrl key and ⌘ key on macOS" }