From 5c4cc81b0533e20be25e92c48fd5d08c17adab16 Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 29 Jul 2026 20:07:17 +0200 Subject: [PATCH] Revert "feat: refine mobile dashboard experience" This reverts commit 315520becab2d2a5c692e0244f237ef903629e0c. --- apps/docs/docs/management/boards/index.mdx | 79 ++---- .../[locale]/boards/(content)/_creator.tsx | 30 +-- .../boards/(content)/_header-actions.tsx | 85 ++---- .../boards/(content)/_prefetch-policy.spec.ts | 17 -- .../boards/(content)/_prefetch-policy.ts | 7 - .../[locale]/boards/_board-switcher-logo.tsx | 20 +- .../components/board/items/item-content.tsx | 21 +- .../board/mobile/deferred-mobile-item.spec.ts | 18 -- .../board/mobile/deferred-mobile-item.tsx | 57 +--- .../board/mobile/mobile-board.module.css | 34 +-- .../components/board/mobile/mobile-board.tsx | 22 +- .../board/mobile/mobile-dialog-stack.spec.ts | 35 --- .../board/mobile/mobile-dialog-stack.ts | 47 ---- .../board/mobile/mobile-layout.spec.ts | 30 --- .../components/board/mobile/mobile-layout.ts | 12 +- .../board/mobile/mobile-presentation.spec.ts | 74 +----- .../board/mobile/mobile-presentation.ts | 18 +- .../mobile/mobile-section-navigation.spec.ts | 28 -- .../board/mobile/mobile-section-navigation.ts | 13 - .../board/mobile/mobile-widget-actions.tsx | 119 +++------ .../src/components/layout/header/search.tsx | 11 +- e2e/mobile-board.spec.ts | 243 ------------------ packages/api/src/router/board.ts | 6 +- packages/api/src/router/test/board.spec.ts | 67 +---- packages/api/src/router/widgets/calendar.ts | 31 ++- packages/api/src/router/widgets/downloads.ts | 20 +- .../src/router/widgets/health-monitoring.ts | 22 +- .../api/src/router/widgets/media-release.ts | 30 +-- .../api/src/router/widgets/media-requests.ts | 24 +- .../api/src/router/widgets/media-server.ts | 16 +- .../api/src/router/widgets/notifications.ts | 30 +-- .../api/src/router/widgets/uptime-kuma.ts | 26 +- packages/api/src/settle-integrations.spec.ts | 43 ---- packages/translation/src/lang/en.json | 12 +- packages/widgets/src/anchor-note/index.ts | 2 - packages/widgets/src/app/component.tsx | 21 +- .../widgets/src/archive-team-warrior/index.ts | 1 - packages/widgets/src/audio-stats/index.ts | 2 - packages/widgets/src/bazarr/index.ts | 1 - .../widgets/src/beszel-alerts/component.tsx | 20 -- packages/widgets/src/beszel-alerts/index.ts | 8 - .../src/beszel-system-grid/component.tsx | 19 +- .../widgets/src/beszel-system-grid/index.ts | 7 - .../src/beszel-system-stats/component.tsx | 41 +-- .../widgets/src/beszel-system-stats/index.ts | 7 - .../src/beszel-system-table/component.tsx | 19 +- .../widgets/src/beszel-system-table/index.ts | 7 - .../src/beszel/_shared/mobile-summary.tsx | 44 ---- packages/widgets/src/bookmarks/index.tsx | 2 - packages/widgets/src/calendar/component.tsx | 67 ++--- packages/widgets/src/calendar/index.ts | 1 - .../widgets/src/common/mobile-summary.tsx | 50 +--- .../src/coolify/applications-section.tsx | 12 +- packages/widgets/src/coolify/component.tsx | 30 +-- packages/widgets/src/coolify/index.ts | 2 - .../widgets/src/coolify/instance-card.tsx | 18 +- packages/widgets/src/coolify/resource-row.tsx | 43 +--- .../widgets/src/coolify/servers-section.tsx | 21 +- .../widgets/src/coolify/services-section.tsx | 12 +- .../src/coolify/single-instance-layout.tsx | 24 +- packages/widgets/src/custom-api/component.tsx | 11 +- packages/widgets/src/custom-api/index.ts | 2 - .../custom-api/jsx-interactive-components.tsx | 18 +- packages/widgets/src/definition.ts | 7 +- .../src/dns-hole/controls/TimerModal.tsx | 36 +-- .../src/dns-hole/controls/component.tsx | 4 - .../widgets/src/dns-hole/controls/index.ts | 1 - packages/widgets/src/docker/component.tsx | 54 +--- packages/widgets/src/docker/index.ts | 7 - packages/widgets/src/downloads/index.ts | 2 - packages/widgets/src/firewall/index.ts | 2 - .../src/health-monitoring/component.tsx | 107 ++------ .../widgets/src/health-monitoring/index.ts | 8 - .../src/health-monitoring/system-health.tsx | 17 +- packages/widgets/src/iframe/component.tsx | 18 +- packages/widgets/src/iframe/index.ts | 6 - packages/widgets/src/indexer-manager/index.ts | 2 - .../widgets/src/media-missing/component.tsx | 15 +- packages/widgets/src/media-missing/index.ts | 1 - .../widgets/src/media-releases/component.tsx | 19 +- .../src/media-requests/list/component.tsx | 25 +- .../widgets/src/media-requests/list/index.ts | 2 - .../widgets/src/media-requests/stats/index.ts | 3 - .../widgets/src/media-server/component.tsx | 89 ++----- packages/widgets/src/media-server/index.ts | 8 - .../src/media-transcoding/component.tsx | 7 +- .../widgets/src/media-transcoding/index.ts | 1 - packages/widgets/src/notebook/component.tsx | 21 -- packages/widgets/src/notebook/index.ts | 6 - packages/widgets/src/notebook/notebook.tsx | 11 +- .../widgets/src/notifications/component.tsx | 73 +++--- packages/widgets/src/notifications/index.ts | 1 - packages/widgets/src/paperless-ngx/index.ts | 1 - packages/widgets/src/patchmon/index.ts | 50 +--- packages/widgets/src/releases/index.ts | 2 - packages/widgets/src/rssFeed/component.tsx | 84 +++--- packages/widgets/src/rssFeed/index.ts | 2 - .../src/smart-home/entity-state/index.ts | 1 - .../widgets/src/speedtest-tracker/index.ts | 2 - packages/widgets/src/stocks/index.ts | 2 - .../widgets/src/system-disks/component.tsx | 47 +--- packages/widgets/src/system-disks/index.ts | 7 - .../src/system-resources/component.tsx | 85 +----- .../widgets/src/system-resources/index.ts | 7 - packages/widgets/src/timetable/index.ts | 2 - packages/widgets/src/tracearr/index.ts | 2 - packages/widgets/src/traefik/index.ts | 2 - packages/widgets/src/umami/index.ts | 2 - packages/widgets/src/ups/index.ts | 2 - .../widgets/src/uptime-kuma/component.tsx | 58 +---- packages/widgets/src/uptime-kuma/index.ts | 8 - packages/widgets/src/vpn/index.ts | 2 - packages/widgets/src/weather/component.tsx | 102 +++----- packages/widgets/src/weather/index.ts | 2 - 114 files changed, 535 insertions(+), 2349 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts delete mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts delete mode 100644 apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts delete mode 100644 e2e/mobile-board.spec.ts delete mode 100644 packages/api/src/settle-integrations.spec.ts delete mode 100644 packages/widgets/src/beszel/_shared/mobile-summary.tsx diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index b6cefdac0..d8724f01e 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,9 +9,9 @@ tags: - Layout --- -import iconColorImage from "./img/icon-color.png"; -import itemRadiusImage from "./img/item-radius.png"; -import layoutLargeImage from "./img/layout-large.png"; +import iconColorImage from './img/icon-color.png' +import itemRadiusImage from './img/item-radius.png' +import layoutLargeImage from './img/layout-large.png' Each dashboard is called a "board". You can create as many boards as you want. @@ -22,29 +22,24 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board - When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name - The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count - Homarr has a complex drag and drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public - This will enable public access for everyone - regardless of whether they are logged in or not. This can be really useful if you want to have public boards in your company or for family and fiends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings - You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -54,9 +49,7 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) ### General - #### Page title - This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -64,7 +57,6 @@ This is the title that will be visible at the top left in the navigation bar: This field is optional and Homarr will fall back to "Homarr" if nothing is set. #### Meta title - This is the secondary title of the page that is not directly visible in the page itself. Browser will use it to set the title of tabs: @@ -73,12 +65,10 @@ Browser will use it to set the title of tabs: Homarr will fall back to the board name and a suffix if nothing is set. #### Logo image URL - This is the URL that is used for the image at the top left. Homarr will fall back to the Homarr logo if nothing is set. #### Favicon image URL - This is the URL that is used for the favicon in the tab title. Homarr will fall back to the Homarr logo if nothing is set. @@ -102,55 +92,41 @@ App items use compact tiles. Widgets can opt into a compact summary, and larger To rearrange, add, remove, resize, or configure board items, open the board on a larger screen. ### Background - #### Background image URL - Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment - This defines whether the background should move when the user scrolls. #### Background image size - This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat - This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance - #### Primary color - Color that can be chosen freely to give accents to active elements. - #### Secondary color - Color that can be chosen freely to give accents. - #### Opacity - Opacity of the colors. - #### Icon color - Change the color of your bookmark / app icons. - + #### Item radius - Change the radius of your items / categories. - + + ### Custom CSS - This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -159,16 +135,14 @@ The styling will only be applied on the respective board page. ### Behaviour #### Disable app status - This will disable the status check for apps. ### Access control - With the access control, you can permit a single user or a group to your board. The users and groups can have one of these three levels of access: | **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) | -| -------------------- | ---------- | --------- | -------- | ------------------------ | +|----------------------|------------|-----------|----------|--------------------------| | View board | X | | | | | Modify board | X | X | X | | | Full access | X | X | X | X | @@ -187,23 +161,18 @@ After editing permissions, the save button below the table must be pressed. Otherwise, all changes will be discarded. ### Danger zone - #### Rename - Renaming a board will break all existing links to it permanently with no redirect. #### Board visibility - By default, boards will be private. This means only authenticated users that are authorized have access. Making a board public will expose it to everyone - including unauthenticated users. #### Delete - This will permanently delete the board. This action is irreversible and cannot be undone. ## Delete boards - The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: @@ -215,7 +184,7 @@ Both actions will execute the same deletion process. ## Duplicate boards :::note -Duplicating a board will not copy the users and groups that have access to the board +Duplicating a board will not copy the users and groups that have access to the board nor the integrations the user does not have access to. ::: @@ -225,51 +194,47 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board - The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: - -- `my-homarr.com` -- `my-homarr.com/boards` +- ``my-homarr.com`` +- ``my-homarr.com/boards`` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself - To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: - 1. Click on your user profile at the top right -2. Click on `Your preferences` -3. Change the home board in the `home board` dropdown and click on `Save`. +2. Click on ``Your preferences`` +3. Change the home board in the ``home board`` dropdown and click on ``Save``. ### For other users -1. Navigate to the users: `Management` > `Users` > `Manage` +1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on `Save` \ - ![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on ``Save`` \ +![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` -2. Go to tab `Settings` -3. Select the desired home board via the dropdown and click on `Save` \ - ![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` +2. Go to tab ``Settings`` +3. Select the desired home board via the dropdown and click on ``Save`` \ +![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: `Management` > `Settings` > `Boards` -2. Select the desired home board via the dropdown and click on `Save` \ - ![](./img/set-home-board-global.png) +1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` +2. Select the desired home board via the dropdown and click on ``Save`` \ +![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 5ec14adfa..82dce0ba3 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { headers } from "next/headers"; import { Suspense } from "react"; import { TRPCError } from "@trpc/server"; @@ -15,7 +14,6 @@ import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { isNullOrWhitespace } from "@homarr/common"; -import { userAgent } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { WidgetKind } from "@homarr/definitions"; @@ -29,7 +27,6 @@ import { createBoardLayout } from "../_layout-creator"; import type { Board, Item } from "../_types"; import { DynamicClientBoard } from "./_dynamic-client"; import { BoardContentHeaderActions } from "./_header-actions"; -import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; const logger = createLogger({ module: "createBoardContentPage" }); @@ -54,8 +51,7 @@ export const createBoardContentPage = >( const resolvedParams = await params; const queryClient = getQueryClient(); - const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]); - const deviceType = userAgent(new Headers(requestHeaders)).device.type; + const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); const itemsMap = board.items.reduce((acc, item) => { const existing = acc.get(item.kind); @@ -69,19 +65,17 @@ export const createBoardContentPage = >( const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), - ...Array.from(itemsMap) - .filter(([kind]) => shouldPrefetchWidgetForRequest(kind, deviceType)) - .map(([kind, items]) => - prefetchForKindAsync(kind, queryClient, items).catch((error) => { - logger.error( - new ErrorWithMetadata( - "Failed to prefetch widget", - { widgetKind: kind, itemCount: items.length }, - { cause: error }, - ), - ); - }), - ), + ...Array.from(itemsMap).map(([kind, items]) => + prefetchForKindAsync(kind, queryClient, items).catch((error) => { + logger.error( + new ErrorWithMetadata( + "Failed to prefetch widget", + { widgetKind: kind, itemCount: items.length }, + { cause: error }, + ), + ); + }), + ), ]); const userId = session?.user.id ?? "anonymous"; 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 e6c8093af..46d9dec3b 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,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core"; @@ -47,7 +47,6 @@ 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 { createMobileBoardElements } from "~/components/board/mobile/mobile-layout"; -import { focusMobileBoardSection } from "~/components/board/mobile/mobile-section-navigation"; import { useIsMobileBoard } from "~/components/board/use-mobile-board"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox"; @@ -269,7 +268,6 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { const tProfile = useScopedI18n("common.userAvatar.menu"); const reduceMotion = useReducedMotion(); const [opened, disclosure] = useDisclosure(false); - const pendingSectionAnchorId = useRef(null); const desktopLayout = getDesktopLayout(board); const sections = useMemo( () => createMobileBoardElements(board, desktopLayout.id).filter((element) => element.type === "sectionHeading"), @@ -277,16 +275,12 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { ); const jumpToSection = (anchorId: string) => { - pendingSectionAnchorId.current = anchorId; disclosure.close(); - }; - - const focusPendingSection = () => { - const anchorId = pendingSectionAnchorId.current; - pendingSectionAnchorId.current = null; - if (!anchorId) return; - - focusMobileBoardSection({ anchorId, reduceMotion }); + requestAnimationFrame(() => { + const heading = document.getElementById(anchorId); + heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); + heading?.focus({ preventScroll: true }); + }); }; return ( @@ -302,53 +296,8 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { size="auto" padding="md" overlayProps={{ backgroundOpacity: 0.45, blur: 2 }} - onExitTransitionEnd={focusPendingSection} - styles={{ - content: { - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, - }} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} > - {sections.length >= 2 && ( - <> - - - - {t("board.mobile.sections")} - - - {sections.map((section) => ( - - ))} - - - )} )} + {sections.length >= 2 && ( + <> + {showSettings && } + + + + {t("board.mobile.sections")} + + + {sections.map((section) => ( + + ))} + + )} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts deleted file mode 100644 index 448119508..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; - -describe("shouldPrefetchWidgetForRequest", () => { - test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => { - expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false); - }); - - test("keeps downloads prefetched for desktop requests", () => { - expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true); - }); - - test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => { - expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true); - }); -}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts deleted file mode 100644 index c5a89a754..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { WidgetKind } from "@homarr/definitions"; - -export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => { - const isMobileDevice = deviceType === "mobile" || deviceType === "tablet"; - - return kind !== "downloads" || !isMobileDevice; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx index 7b4551ff5..f28a04489 100644 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -8,22 +8,12 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useIsMobileBoard } from "~/components/board/use-mobile-board"; -import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; +import { BoardLogo } from "~/components/layout/logo/board-logo"; export const BoardSwitcherLogo = () => { const board = useRequiredBoard(); + const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const t = useI18n(); - const isMobileBoard = useIsMobileBoard(); - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isMobileBoard }); - - if (!isMobileBoard) { - return ( - - - - ); - } return ( @@ -36,7 +26,7 @@ export const BoardSwitcherLogo = () => { > - + {board.name} @@ -56,9 +46,7 @@ export const BoardSwitcherLogo = () => { leftSection={isCurrent ? : } aria-current={isCurrent ? "page" : undefined} > - - {availableBoard.name} - + {availableBoard.name} ); })} diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 37601c4ee..c41f23356 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -26,7 +26,6 @@ interface BoardItemContentProps { item: SectionItem; displayMode?: WidgetDisplayMode; disableContextMenu?: boolean; - isReadOnly?: boolean; widgetStateRef?: MutableRefObject | null>; } @@ -40,7 +39,6 @@ export const BoardItemContent = ({ item, displayMode = "default", disableContextMenu = false, - isReadOnly = false, widgetStateRef: externalWidgetStateRef, }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); @@ -72,7 +70,6 @@ export const BoardItemContent = ({ width={width} height={height} displayMode={displayMode} - isReadOnly={isReadOnly} widgetStateRef={widgetStateRef} /> @@ -91,8 +88,9 @@ export const BoardItemContent = ({ | null>; } -const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); @@ -145,7 +142,7 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - {!isReadOnly && } + )} @@ -158,23 +155,21 @@ const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerCon (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - {!isReadOnly && } + { - if (isReadOnly) return; + setOptions={(partialNewOptions) => updateOptions({ newOptions: { ...options, ...partialNewOptions.newOptions, }, - }); - }} + }) + } displayMode={displayMode} {...dimensions} /> diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts deleted file mode 100644 index 956bce38c..000000000 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item"; - -describe("getMobilePreloadRootMargin", () => { - test.each([ - [568, "1136px 0px"], - [844, "1688px 0px"], - [932, "1864px 0px"], - ])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => { - expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected); - }); - - test("keeps the preload policy explicit and prevents negative margins", () => { - expect(mobilePreloadViewportMultiplier).toBe(2); - expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px"); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx index de0fa9d98..ea142a886 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -8,25 +8,14 @@ import classes from "./mobile-board.module.css"; interface DeferredMobileItemProps extends PropsWithChildren { eager: boolean; - unmountWhenOffscreen: boolean; } -export const mobilePreloadViewportMultiplier = 2; - -export const getMobilePreloadRootMargin = (viewportHeight: number) => - `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; - -export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => { +export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => { const [hasMounted, setHasMounted] = useState(eager); const wrapperRef = useRef(null); - const hasMountedOnceRef = useRef(eager); useEffect(() => { - if (eager) { - hasMountedOnceRef.current = true; - setHasMounted(true); - return; - } + if (eager || hasMounted) return; const element = wrapperRef.current; if (!element || typeof IntersectionObserver === "undefined") { @@ -34,37 +23,17 @@ export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: De return; } - let observer: IntersectionObserver | null = null; - const observe = () => { - if (hasMountedOnceRef.current && !unmountWhenOffscreen) return; - observer?.disconnect(); - observer = new IntersectionObserver( - ([entry]) => { - const isNearViewport = entry?.isIntersecting ?? false; - - if (isNearViewport) { - hasMountedOnceRef.current = true; - setHasMounted(true); - if (!unmountWhenOffscreen) observer?.disconnect(); - return; - } - - if (unmountWhenOffscreen && !element.contains(document.activeElement)) { - setHasMounted(false); - } - }, - { rootMargin: getMobilePreloadRootMargin(window.innerHeight) }, - ); - observer.observe(element); - }; - - observe(); - window.addEventListener("resize", observe); - return () => { - window.removeEventListener("resize", observe); - observer?.disconnect(); - }; - }, [eager, unmountWhenOffscreen]); + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + setHasMounted(true); + observer.disconnect(); + }, + { rootMargin: "200% 0px" }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [eager, hasMounted]); return (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index 78026b2f1..fae316cc7 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -6,18 +6,6 @@ overflow-x: clip; } -.boardTitle { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - .grid { display: grid; grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); @@ -30,6 +18,8 @@ position: relative; min-width: 0; min-height: 0; + content-visibility: auto; + contain-intrinsic-size: auto 12rem; } .deferredItem { @@ -44,18 +34,9 @@ } .sectionHeading { - width: fit-content; - max-width: 100%; scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md)); - padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); - color: var(--mantine-color-text); - background: var(--mantine-color-body); - border: 1px solid var(--mantine-color-default-border); - border-radius: var(--mantine-radius-md); - box-shadow: var(--mantine-shadow-xs); + padding-block: var(--mantine-spacing-xs); outline-offset: 4px; - overflow-wrap: anywhere; - text-wrap: pretty; } .empty { @@ -67,21 +48,14 @@ position: absolute; z-index: 11; top: 4px; - inset-inline-end: 4px; + right: 4px; border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent); box-shadow: var(--mantine-shadow-sm); } .detailContent, .detailContent > :global(.grid-stack-item-content) { - position: relative; width: 100%; height: 100%; min-height: 24rem; } - -.detailContent :global(.mantine-ActionIcon-root), -.detailContent :global(.mantine-Button-root) { - min-width: 44px; - min-height: 44px; -} diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 6f67e977f..918ea6217 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -13,7 +13,6 @@ import { widgetImports } from "@homarr/widgets"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; -import { useBoardPermissions } from "../permissions/client"; import { SectionProvider } from "../sections/section-context"; import { DeferredMobileItem } from "./deferred-mobile-item"; import classes from "./mobile-board.module.css"; @@ -71,16 +70,19 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { data-mobile-board-item={item.id} data-mobile-display-mode={presentation.displayMode} > - + + - ); }; @@ -88,7 +90,6 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { export const MobileBoard = () => { const board = useRequiredBoard(); const t = useI18n(); - const { hasChangeAccess } = useBoardPermissions(board); const desktopLayout = getDesktopLayout(board); const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]); const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]); @@ -109,9 +110,6 @@ export const MobileBoard = () => { }} > - - {board.name} - {elements.length === 0 ? (
@@ -122,7 +120,7 @@ export const MobileBoard = () => { {t("board.mobile.empty.title")} - {t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")} + {t("board.mobile.empty.description")}
@@ -132,12 +130,10 @@ export const MobileBoard = () => { {block.title} diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts deleted file mode 100644 index 374944e99..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { hasOpenDialogOutside } from "./mobile-dialog-stack"; - -describe("hasOpenDialogOutside", () => { - test("detects an open child dialog outside the details portal", () => { - const root = document.createElement("div"); - const details = document.createElement("div"); - const child = document.createElement("div"); - details.setAttribute("role", "dialog"); - child.setAttribute("role", "dialog"); - root.append(details, child); - - expect(hasOpenDialogOutside(details, root)).toBe(true); - }); - - test("ignores hidden and closed dialogs", () => { - const root = document.createElement("div"); - const details = document.createElement("div"); - const hiddenChild = document.createElement("div"); - details.setAttribute("role", "dialog"); - hiddenChild.setAttribute("role", "dialog"); - hiddenChild.setAttribute("aria-hidden", "true"); - root.append(details, hiddenChild); - - expect(hasOpenDialogOutside(details, root)).toBe(false); - }); - - test("does not treat the current details dialog as nested", () => { - const details = document.createElement("div"); - details.setAttribute("role", "dialog"); - - expect(hasOpenDialogOutside(details, details)).toBe(false); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts deleted file mode 100644 index b46eaf9b3..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import type { RefObject } from "react"; -import { useEffect, useState } from "react"; - -const getDialogElement = (element: HTMLElement | null) => { - if (!element) return null; - if (element.matches('[role="dialog"]')) return element; - return element.closest('[role="dialog"]') ?? element.querySelector('[role="dialog"]'); -}; - -const isOpenDialog = (element: Element) => - element.getAttribute("aria-hidden") !== "true" && - element.getAttribute("data-hidden") !== "true" && - element.getAttribute("data-state") !== "closed" && - !element.hasAttribute("hidden"); - -export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => { - const currentDialog = getDialogElement(element); - - return Array.from(root.querySelectorAll('[role="dialog"]')).some( - (dialog) => dialog !== currentDialog && isOpenDialog(dialog), - ); -}; - -export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject) => { - const [hasNestedDialog, setHasNestedDialog] = useState(false); - - useEffect(() => { - if (!opened || typeof MutationObserver === "undefined") return; - - const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current)); - update(); - - const observer = new MutationObserver(update); - observer.observe(document.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"], - }); - - return () => observer.disconnect(); - }, [dialogRef, opened]); - - return opened && hasNestedDialog; -}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index 1a917d8fb..f617694d5 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -147,10 +147,7 @@ describe("createMobileBoardItems", () => { type: "sectionHeading", sectionId: category.id, anchorId: getMobileSectionAnchorId(category.id), - headingLevel: 2, }); - expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 }); - expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 }); }); test("omits headings for empty and unnamed sections while preserving their items", () => { @@ -183,31 +180,4 @@ describe("createMobileBoardItems", () => { expect(result.map((element) => element.type)).toEqual(["item", "item"]); expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]); }); - - test("does not skip heading levels when unnamed sections are flattened", () => { - const board = new BoardMockBuilder().build(); - const desktopLayoutId = board.layouts.at(0)?.id; - if (!desktopLayoutId) throw new Error("Expected a desktop layout"); - - const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build(); - const namedDynamicSection = new DynamicSectionMockBuilder({ - id: "dynamic", - options: { title: "Services", borderColor: "", customCssClasses: [] }, - }) - .addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id }) - .build(); - - board.sections.push(unnamedCategory, namedDynamicSection); - board.items.push( - new ItemMockBuilder({ id: "item" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id }) - .build(), - ); - - expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({ - type: "sectionHeading", - title: "Services", - headingLevel: 2, - }); - }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index f394647d7..1017856a4 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -19,7 +19,6 @@ export type MobileBoardSectionHeading = { sectionId: string; title: string; anchorId: string; - headingLevel: 2 | 3 | 4 | 5 | 6; }; export type MobileBoardElement = SectionItem | MobileBoardSectionHeading; @@ -52,10 +51,7 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -const createSectionHeading = ( - section: CategorySection | DynamicSectionItem, - headingDepth: number, -): MobileBoardSectionHeading | null => { +const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => { const title = (section.kind === "category" ? section.name : section.options.title).trim(); if (title.length === 0) return null; @@ -65,7 +61,6 @@ const createSectionHeading = ( sectionId: section.id, title, anchorId: getMobileSectionAnchorId(section.id), - headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"], }; }; @@ -117,18 +112,16 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) const createSectionElements = ( section: CategorySection | DynamicSectionItem | (typeof rootSections)[number], - headingDepth = 0, ): { elements: MobileBoardElement[]; items: SectionItem[] } => { if (visitedSections.has(section.id)) return { elements: [], items: [] }; const sectionId = section.id; visitedSections.add(sectionId); - const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth); const sectionElements: MobileBoardElement[] = []; const sectionItems: SectionItem[] = []; for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) { if (element.type === "section") { - const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth); + const nestedResult = createSectionElements(element); sectionElements.push(...nestedResult.elements); sectionItems.push(...nestedResult.items); continue; @@ -143,6 +136,7 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) if (sectionItems.length === 0) return { elements: [], items: [] }; + const heading = section.kind === "empty" ? null : createSectionHeading(section); return { elements: heading ? [heading, ...sectionElements] : sectionElements, items: sectionItems, diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index a145bc20b..c2e6c1197 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - isMobileContextActionVisible, - resolveMobileItemPresentation, - shouldRenderMobileWidgetActions, -} from "./mobile-presentation"; +import { resolveMobileItemPresentation } from "./mobile-presentation"; describe("resolveMobileItemPresentation", () => { test("renders apps as eager compact tiles", () => { @@ -13,51 +9,19 @@ describe("resolveMobileItemPresentation", () => { height: 1, eager: true, supportsDetails: false, - unmountWhenOffscreen: false, }); }); - test("defaults widgets to full width without inventing a detail view", () => { + test("defaults widgets to full width and clamps desktop height", () => { expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ width: 2, height: 3, eager: false, - supportsDetails: false, - unmountWhenOffscreen: false, - }); - }); - - test("enables details only when the widget explicitly supports them", () => { - expect( - resolveMobileItemPresentation( - { kind: "calendar", height: 2 }, - { - width: 2, - height: 1, - supportsDetailView: true, - }, - ), - ).toMatchObject({ supportsDetails: true, }); }); - test("keeps automatic footprint defaults when only details are configured", () => { - expect( - resolveMobileItemPresentation( - { kind: "calendar", height: 7 }, - { - supportsDetailView: true, - }, - ), - ).toMatchObject({ - width: 2, - height: 3, - supportsDetails: true, - }); - }); - - test("prefers explicit presentation metadata while eager mounting takes precedence", () => { + test("prefers explicit presentation metadata", () => { expect( resolveMobileItemPresentation( { kind: "clock", height: 3 }, @@ -67,7 +31,6 @@ describe("resolveMobileItemPresentation", () => { supportsCompactSummary: true, supportsDetailView: false, eager: true, - unmountWhenOffscreen: true, }, ), ).toEqual({ @@ -76,37 +39,6 @@ describe("resolveMobileItemPresentation", () => { displayMode: "mobileSummary", supportsDetails: false, eager: true, - unmountWhenOffscreen: false, }); }); }); - -describe("shouldRenderMobileWidgetActions", () => { - test("does not render an action trigger without an explicit capability", () => { - expect( - shouldRenderMobileWidgetActions({ - supportsDetails: false, - supportsRefresh: false, - visibleContextActionCount: 0, - }), - ).toBe(false); - }); - - test.each([ - { supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 }, - { supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 }, - { supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 }, - ])("renders an action trigger for an explicit capability", (capabilities) => { - expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true); - }); -}); - -describe("isMobileContextActionVisible", () => { - test("requires actions to opt into the read-only mobile surface", () => { - const action = { key: "configure", label: "Configure", onClick: () => undefined }; - - expect(isMobileContextActionVisible(action)).toBe(false); - expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true); - expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index 41917e4a1..62dbd65aa 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -1,5 +1,5 @@ import type { WidgetKind } from "@homarr/definitions"; -import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets"; +import type { WidgetMobilePresentation } from "@homarr/widgets"; export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, @@ -8,20 +8,6 @@ export const resolveMobileItemPresentation = ( width: mobile?.width ?? (item.kind === "app" ? 1 : 2), height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), - supportsDetails: mobile?.supportsDetailView === true, + supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app", eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), - unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), }); - -export const shouldRenderMobileWidgetActions = ({ - supportsDetails, - supportsRefresh, - visibleContextActionCount, -}: { - supportsDetails: boolean; - supportsRefresh: boolean; - visibleContextActionCount: number; -}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0; - -export const isMobileContextActionVisible = (action: WidgetContextMenuAction) => - action.mobileVisible === true && !action.hidden; diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts deleted file mode 100644 index 180699fbf..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -import { focusMobileBoardSection } from "./mobile-section-navigation"; - -afterEach(() => { - document.body.replaceChildren(); -}); - -describe("focusMobileBoardSection", () => { - test.each([ - [false, "smooth"], - [true, "auto"], - ] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => { - const heading = document.createElement("h2"); - heading.id = "section"; - heading.tabIndex = -1; - heading.scrollIntoView = vi.fn(); - document.body.append(heading); - - expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true); - expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" }); - expect(document.activeElement).toBe(heading); - }); - - test("does nothing when the section no longer exists", () => { - expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts deleted file mode 100644 index ad426b5c5..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts +++ /dev/null @@ -1,13 +0,0 @@ -interface FocusMobileBoardSectionOptions { - anchorId: string; - reduceMotion: boolean; -} - -export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => { - const heading = document.getElementById(anchorId); - if (!heading) return false; - - heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); - heading.focus({ preventScroll: true }); - return true; -}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx index 60f50c234..d05b1ebcf 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject } from "react"; -import { useMemo, useRef } from "react"; +import { useMemo } from "react"; import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; @@ -18,9 +18,8 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; -import { useHasNestedDialog } from "./mobile-dialog-stack"; +import { useItemActions } from "../items/item-actions"; import classes from "./mobile-board.module.css"; -import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation"; interface MobileWidgetActionsProps { item: SectionItem; @@ -31,16 +30,18 @@ interface MobileWidgetActionsProps { export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { const [actionsOpened, actionsDisclosure] = useDisclosure(false); const [detailsOpened, detailsDisclosure] = useDisclosure(false); - const actionTriggerRef = useRef(null); - const detailsDialogRef = useRef(null); - const openDetailsAfterActionsRef = useRef(false); - const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef); const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); + const { updateItemOptions } = useItemActions(); const definition = widgetImports[item.kind].definition; - const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined; + const queryClient = useQueryClient(); + const queryKey = useMemo( + () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]), + [definition, item.kind], + ); + const isFetching = useIsFetching({ queryKey }) > 0; const options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, [item.kind, item.options, settings], @@ -50,32 +51,36 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M return definition.contextActions({ options, - setOptions: () => undefined, + setOptions: (partial: Record) => + updateItemOptions({ + itemId: item.id, + newOptions: { ...options, ...partial }, + }), integrationIds: item.integrationIds, context: { isEditMode, boardId: board.id, itemId: item.id }, widgetStateRef, } as never) as WidgetContextMenuAction[]; - }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]); - const visibleContextActions = contextActions.filter(isMobileContextActionVisible); + }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]); + const visibleContextActions = contextActions.filter((action) => !action.hidden); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); - const hasActions = shouldRenderMobileWidgetActions({ - supportsDetails, - supportsRefresh: queryKey !== undefined, - visibleContextActionCount: visibleContextActions.length, - }); + const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0; if (!hasActions) return null; + const refresh = () => { + void queryClient.refetchQueries({ queryKey, type: "all" }); + actionsDisclosure.close(); + }; + return ( <> @@ -89,21 +94,6 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="auto" padding="md" overlayProps={{ backgroundOpacity: 0.45, blur: 2 }} - styles={{ - content: { - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, - }} - onExitTransitionEnd={() => { - if (!openDetailsAfterActionsRef.current) return; - openDetailsAfterActionsRef.current = false; - detailsDisclosure.open(); - }} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} > {supportsDetails && ( @@ -113,8 +103,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="lg" leftSection={} onClick={() => { - openDetailsAfterActionsRef.current = true; actionsDisclosure.close(); + detailsDisclosure.open(); }} > {t("board.mobile.details")} @@ -140,74 +130,37 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M ); })} - {queryKey && } + actionTriggerRef.current?.focus()} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} styles={{ - content: { - display: "flex", - flexDirection: "column", - height: "100dvh", - paddingTop: "env(safe-area-inset-top)", - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, body: { - flex: "1 1 auto", - minHeight: 0, + height: "calc(100dvh - 4rem)", overflowY: "auto", paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", }, }} > - + ); }; - -const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => { - const t = useI18n(); - const queryClient = useQueryClient(); - const isFetching = useIsFetching({ queryKey }) > 0; - - return ( - - ); -}; diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index a17197ba1..95ebcf9f0 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -6,15 +6,11 @@ import { IconSearch } from "@tabler/icons-react"; import { openSpotlight } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; -import { useIsMobileBoard } from "../../board/use-mobile-board"; import { HeaderButton } from "./button"; import classes from "./search.module.css"; export const DesktopSearchInput = () => { const t = useI18n(); - const isMobileBoard = useIsMobileBoard(); - - if (isMobileBoard) return null; return ( { export const MobileSearchButton = () => { const t = useI18n(); - const isMobileBoard = useIsMobileBoard(); return ( - + ); diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts deleted file mode 100644 index ffe381f61..000000000 --- a/e2e/mobile-board.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { chromium, devices, expect } from "@playwright/test"; -import { createId } from "@paralleldrive/cuid2"; -import SuperJSON from "superjson"; -import { describe, test } from "vitest"; - -import { eq } from "@homarr/db"; -import * as sqliteSchema from "@homarr/db/schema/sqlite"; - -import { createHomarrContainer } from "./shared/create-homarr-container"; -import { createSqliteDbFileAsync } from "./shared/e2e-db"; -import type { SqliteDatabase } from "./shared/e2e-db"; -import { seedAdminUserAsync } from "./shared/seed-admin-user"; - -const adminCredentials = { - username: "admin", - password: "Comp(exP4sswOrd", -}; - -const boardName = "mobile-e2e"; - -const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { - const boardId = createId(); - const layoutId = createId(); - const firstSectionId = createId(); - const secondSectionId = createId(); - - await db.insert(sqliteSchema.boards).values({ - id: boardId, - name: boardName, - creatorId: userId, - }); - await db.insert(sqliteSchema.layouts).values({ - id: layoutId, - name: "Desktop", - boardId, - columnCount: 12, - breakpoint: 0, - }); - await db.insert(sqliteSchema.sections).values([ - { - id: firstSectionId, - boardId, - kind: "category", - name: "First section", - xOffset: 0, - yOffset: 0, - }, - { - id: secondSectionId, - boardId, - kind: "category", - name: "Second section", - xOffset: 0, - yOffset: 20, - }, - ]); - - const itemRows = Array.from({ length: 36 }, (_, index) => { - const id = createId(); - return { - id, - boardId, - kind: index === 0 ? ("notebook" as const) : ("clock" as const), - options: SuperJSON.stringify(index === 0 ? { content: "

Mobile notes

" } : {}), - advancedOptions: SuperJSON.stringify({}), - }; - }); - await db.insert(sqliteSchema.items).values(itemRows); - await db.insert(sqliteSchema.itemLayouts).values( - itemRows.map((item, index) => ({ - itemId: item.id, - layoutId, - sectionId: index < 18 ? firstSectionId : secondSectionId, - xOffset: (index % 6) * 2, - yOffset: Math.floor((index % 18) / 6) * 2, - width: 2, - height: 2, - })), - ); - await db - .update(sqliteSchema.users) - .set({ - homeBoardId: boardId, - mobileHomeBoardId: boardId, - completedBoardTour: true, - }) - .where(eq(sqliteSchema.users.id, userId)); -}; - -describe("Automatic mobile board", () => { - test("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => { - const { db, localMountPath } = await createSqliteDbFileAsync(); - const { userId } = await seedAdminUserAsync(db, adminCredentials); - await seedMobileBoardAsync(db, userId); - - const homarrContainer = await createHomarrContainer({ - environment: { - AUTH_PROVIDERS: "credentials", - }, - mounts: { - "/appdata": localMountPath, - }, - }).start(); - const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; - const browser = await chromium.launch(); - const loginContext = await browser.newContext(); - - try { - const loginPage = await loginContext.newPage(); - await loginPage.goto(`${baseUrl}/auth/login`); - await loginPage.getByLabel("Username").fill(adminCredentials.username); - await loginPage.locator("#password").fill(adminCredentials.password); - await loginPage.locator("button[type='submit']").click(); - await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); - const storageState = await loginContext.storageState(); - - const mobileViewports = [ - { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { - name: "phone landscape", - width: 844, - height: 390, - userAgent: devices["iPhone 13"].userAgent, - phone: true, - }, - { - name: "narrow tablet", - width: 768, - height: 1024, - userAgent: devices["iPad (gen 7)"].userAgent, - phone: false, - }, - ]; - - for (const viewport of mobileViewports) { - const context = await browser.newContext({ - storageState, - viewport: { width: viewport.width, height: viewport.height }, - userAgent: viewport.userAgent, - hasTouch: true, - isMobile: viewport.phone, - reducedMotion: viewport.width === 390 ? "reduce" : "no-preference", - }); - const page = await context.newPage(); - await page.goto(`${baseUrl}/boards/${boardName}`); - - await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 }); - await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36); - await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0); - await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible(); - - const layoutMetrics = await page.evaluate(() => { - const grids = Array.from(document.querySelectorAll("[data-mobile-board] > div")); - const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid"); - const items = Array.from(document.querySelectorAll("[data-mobile-board-item]")); - const focusable = Array.from( - document.querySelectorAll( - '[data-mobile-board] a[href], [data-mobile-board] button, [data-mobile-board] input, [data-mobile-board] select, [data-mobile-board] textarea, [data-mobile-board] [tabindex]:not([tabindex="-1"])', - ), - ); - - return { - columns: twoColumnGrids.map( - (grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length, - ), - documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, - overflowingItems: items.filter((item) => item.scrollWidth > item.clientWidth + 1).length, - outOfBoundsControls: focusable.filter((element) => { - const rect = element.getBoundingClientRect(); - return rect.left < -1 || rect.right > window.innerWidth + 1; - }).length, - layoutControls: document.querySelectorAll( - '[data-testid*="layout"], button[aria-label*="resize" i], button[aria-label*="move" i]', - ).length, - }; - }); - - expect( - layoutMetrics.columns.length > 0 && layoutMetrics.columns.every((count) => count === 2), - viewport.name, - ).toBe(true); - expect(layoutMetrics.documentOverflow, viewport.name).toBeLessThanOrEqual(0); - expect(layoutMetrics.overflowingItems, viewport.name).toBe(0); - expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0); - expect(layoutMetrics.layoutControls, viewport.name).toBe(0); - - if (viewport.width === 320) { - await page.evaluate(() => { - document.documentElement.style.fontSize = "200%"; - }); - const zoomOverflow = await page.evaluate(() => ({ - document: document.documentElement.scrollWidth - document.documentElement.clientWidth, - items: Array.from(document.querySelectorAll("[data-mobile-board-item]")).filter( - (item) => item.scrollWidth > item.clientWidth + 1, - ).length, - })); - expect(zoomOverflow.document).toBeLessThanOrEqual(0); - expect(zoomOverflow.items).toBe(0); - } - - if (viewport.width === 390) { - await page.getByRole("button", { name: "More" }).click(); - const moreDrawer = page.getByRole("dialog", { name: "More" }); - await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible(); - await moreDrawer.getByRole("button", { name: "Second section" }).click(); - await expect(page.getByRole("heading", { name: "Second section" })).toBeFocused(); - - const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i }); - await notebookActions.click(); - await page.getByRole("button", { name: "Open widget details" }).click(); - const details = page.getByRole("dialog", { name: "Notebook" }); - await expect(details).toBeVisible(); - await details.getByRole("button", { name: "Close" }).click(); - await expect(notebookActions).toBeFocused(); - - const rtlOverflow = await page.evaluate(() => { - document.documentElement.dir = "rtl"; - return document.documentElement.scrollWidth - document.documentElement.clientWidth; - }); - expect(rtlOverflow).toBeLessThanOrEqual(0); - } - - await context.close(); - } - - const desktopContext = await browser.newContext({ - storageState, - viewport: { width: 800, height: 900 }, - }); - const desktopPage = await desktopContext.newPage(); - await desktopPage.goto(`${baseUrl}/boards/${boardName}`); - await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0); - await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36); - await desktopContext.close(); - } finally { - await loginContext.close(); - await browser.close(); - await homarrContainer.stop(); - } - }, 180_000); -}); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 1993b8231..a51313972 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1592,9 +1592,6 @@ const getHomeIdBoardAsync = async ( user: InferSelectModel | null, deviceType: DeviceType, ) => { - // Mobile home-board selection remains device-based because the server does - // not know the resolved CSS viewport. Tablet rendering is resolved against - // 48em on the client and must not be guessed here. const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId"; if (!user) { @@ -1616,7 +1613,8 @@ const getHomeIdBoardAsync = async ( and( eq(groupMembers.userId, user.id), not(eq(groups.name, everyoneGroup)), - or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))), + not(isNull(groups[settingKey])), + not(isNull(groups.homeBoardId)), ), ) .orderBy(asc(groups.position)) diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 94a46bc31..479448584 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -516,64 +516,6 @@ describe("getHomeBoard should return home board", () => { }); expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view"); }); - test.each([ - { deviceType: "mobile", expectedBoard: "mobile-home" }, - { deviceType: "tablet", expectedBoard: "desktop-home" }, - ] as const)( - "should use the $expectedBoard preference for a $deviceType device", - async ({ deviceType, expectedBoard }) => { - // Arrange - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession }); - - const mobileBoardProps = await createFullBoardAsync(db, "mobile-home"); - const desktopBoardProps = await createFullBoardAsync(db, "desktop-home"); - await db - .update(users) - .set({ - homeBoardId: desktopBoardProps.boardId, - mobileHomeBoardId: mobileBoardProps.boardId, - }) - .where(eq(users.id, defaultCreatorId)); - - // Act - const result = await caller.getHomeBoard(); - - // Assert - expectInputToBeFullBoardWithName(result, { - ...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps), - name: expectedBoard, - }); - }, - ); - test("should return a mobile-only group home board", async () => { - // Arrange - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession }); - - const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home"); - const groupId = createId(); - await db.insert(groups).values({ - id: groupId, - name: "mobile group", - position: 1, - homeBoardId: null, - mobileHomeBoardId: mobileBoardProps.boardId, - }); - await db.insert(groupMembers).values({ - userId: defaultCreatorId, - groupId, - }); - - // Act - const result = await caller.getHomeBoard(); - - // Assert - expectInputToBeFullBoardWithName(result, { - name: "group-mobile-home", - ...mobileBoardProps, - }); - }); test("should return global home board when user doesn't have one", async () => { // Arrange const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); @@ -1485,12 +1427,9 @@ const expectInputToBeFullBoardWithName = ( }; const createFullBoardAsync = async (db: Database, name: string) => { - await db - .insert(users) - .values({ - id: defaultCreatorId, - }) - .onConflictDoNothing(); + await db.insert(users).values({ + id: defaultCreatorId, + }); const boardId = createId(); await db.insert(boards).values({ diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 19437e139..729e82634 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,13 +1,16 @@ import { z } from "zod/v4"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const logger = createLogger({ module: "calendarRouter" }); + export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure .meta({ @@ -27,9 +30,8 @@ export const calendarRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { + const settled = await Promise.allSettled( + ctx.integrations.map(async (integration) => { const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); const { data } = await innerHandler.getDataAsync(); @@ -42,8 +44,25 @@ export const calendarRouter = createTRPCRouter({ kind: integration.kind, }, }; - }, - { throwOnAllFailure: true }, + }), ); + + return settled.flatMap((result, index) => { + if (result.status === "fulfilled") { + return [result.value]; + } + const integration = ctx.integrations[index]; + logger.warn( + new ErrorWithMetadata( + "Calendar integration request failed; skipping events for this integration", + { + integrationId: integration?.id, + integrationKind: integration?.kind, + }, + { cause: result.reason }, + ), + ); + return []; + }); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 0bcd1fc55..bdce2473f 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -24,18 +24,14 @@ export const downloadsRouter = createTRPCRouter({ .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); - const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - data, - }; - }, - { throwOnAllFailure: true }, - ); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); + const { data, timestamp } = await innerHandler.getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + data, + }; + }); }), pause: protectedProcedure .meta({ diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 28be00d4a..1957f459c 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -27,19 +27,15 @@ export const healthMonitoringRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .query(async ({ ctx }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - healthInfo: data, - updatedAt: timestamp, - }; - }, - { throwOnAllFailure: true }, - ); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + healthInfo: data, + updatedAt: timestamp, + }; + }); }), listStorageVolumes: publicProcedure .meta({ diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index 179610e31..f05a46142 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -9,24 +9,20 @@ export const mediaReleaseRouter = createTRPCRouter({ getMediaReleases: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - releases: data, - }; - }, - { throwOnAllFailure: true }, - ); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + releases: data, + }; + }); return results.flatMap((result) => result.releases.map((release) => ({ ...release, diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 700b1d007..fbd192499 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -25,14 +25,10 @@ export const mediaRequestsRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { - const results = await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }, - { throwOnAllFailure: true }, - ); + const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); + return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; + }); return results .flatMap(({ data, integration }) => data.map((request) => ({ @@ -61,14 +57,10 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }, - { throwOnAllFailure: true }, - ); + const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); + return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; + }); return { stats: results.flatMap((result) => result.data.stats), users: results diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 03324ab62..3e3e64a4f 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -23,15 +23,11 @@ export const mediaServerRouter = createTRPCRouter({ .concat(createMediaServerIntegrationMiddleware("query")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const { data } = await mediaServerRequestHandler - .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) - .getDataAsync(); - return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; - }, - { throwOnAllFailure: true }, - ); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { data } = await mediaServerRequestHandler + .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) + .getDataAsync(); + return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; + }); }), }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index 2788cb503..bae8ba9fd 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -13,23 +13,19 @@ export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure .unstable_concat(createNotificationsIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = notificationsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = notificationsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - data, - }; - }, - { throwOnAllFailure: true }, - ); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, + }; + }); }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 2ec1f1045..6df14090c 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -8,21 +8,17 @@ export const uptimeKumaRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }, - { throwOnAllFailure: true }, - ); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }); }), }); diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts deleted file mode 100644 index fe3b460d5..000000000 --- a/packages/api/src/settle-integrations.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it, vi } from "vitest"; - -import { settleIntegrationQueries } from "./settle-integrations"; - -const integrations = [ - { id: "first", name: "First", kind: "mock" }, - { id: "second", name: "Second", kind: "mock" }, -]; - -describe("settleIntegrationQueries", () => { - it("retains successful integration results when another integration fails", async () => { - const results = await settleIntegrationQueries( - integrations, - vi.fn(async (integration) => { - if (integration.id === "first") throw new Error("offline"); - return integration.name; - }), - { throwOnAllFailure: true }, - ); - - expect(results).toEqual(["Second"]); - }); - - it("throws when every integration fails and throwOnAllFailure is enabled", async () => { - await expect( - settleIntegrationQueries( - integrations, - vi.fn(async () => { - throw new Error("offline"); - }), - { throwOnAllFailure: true }, - ), - ).rejects.toThrow("offline"); - }); - - it("does not treat an empty integration list as a failure", async () => { - await expect( - settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }), - ).resolves.toEqual([]); - }); -}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5a66c98a6..0bf1a888f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1853,8 +1853,6 @@ "internalServerError": "Failed to control DNS Hole" }, "controls": { - "enable": "Enable", - "disable": "Disable", "enableAll": "Enable All", "disableAll": "Disable All", "setTimer": "Set Timer", @@ -3888,8 +3886,7 @@ } }, "status": { - "unhealthy": "Unhealthy", - "free": "{value}% free" + "unhealthy": "Unhealthy" } }, "customApi": { @@ -4167,18 +4164,15 @@ }, "board": { "mobile": { - "actions": "Actions for {title}", + "actions": "Widget actions", "details": "Open widget details", "empty": { "title": "This board is empty", - "editorDescription": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically.", - "viewerDescription": "No items have been added to this board yet." + "description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically." }, "more": "More", "profile": "Profile and account", "sections": "Jump to section", - "dataWarning": "Data may be incomplete or outdated", - "systemStatus": "{up} up · {attention} need attention", "currentBoard": "Current board" }, "action": { diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts index 7c72f5e5d..a54aada3f 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -5,8 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("anchorNote", { icon: IconNotes, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "anchorNotes"]], supportedIntegrations: ["anchor"], createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 1d9f40dde..56e079a8c 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -14,21 +14,16 @@ import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; -import { WidgetMobileLoading } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; import { PingIndicator } from "./ping/ping-indicator"; -export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) { +export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) { const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const { - data: app, - error, - isPending, - } = clientApi.app.byId.useQuery({ + const { data: app } = clientApi.app.byId.useQuery({ id: options.appId, }); useRegisterSpotlightContextResults( @@ -53,14 +48,10 @@ export default function AppWidget({ options, isEditMode, height, width, displayM [app, options.openInNewTab], ); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !app) throw error; if (!app) return ; - const isMobileSummary = displayMode === "mobileSummary"; - const isTiny = !isMobileSummary && (height < 100 || width < 100); - const layout = isMobileSummary ? "column" : options.layout; - const isColumnLayout = layout.startsWith("column"); + const isTiny = height < 100 || width < 100; + const isColumnLayout = options.layout.startsWith("column"); return ( )} - {!isMobileSummary && options.descriptionDisplayMode === "normal" && ( + {options.descriptionDisplayMode === "normal" && ( ({ showBroadcastMessage: factory.switch({ diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index 70214147f..25af0bdaf 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -14,8 +14,6 @@ const hideUnlessAudiobookshelf = { export const { definition, componentLoader } = createWidgetDefinition("audioStats", { icon: IconHeadphones, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "audioStats"]], supportedIntegrations: ["navidrome", "audiobookshelf"], integrationsRequired: true, maxIntegrations: 1, diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index 10b6e46bc..f2e79f570 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -5,7 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { icon: IconBadgeCc, - queryKey: [["widget", "bazarr"]], supportedIntegrations: ["bazarr"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 708094f8c..3f934423d 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -13,7 +13,6 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; const alertIconMap: Record = { CPU: Cpu, @@ -36,7 +35,6 @@ export default function BeszelAlertsWidget({ options, integrationIds, isEditMode, - displayMode, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const alertsInput = useMemo( @@ -70,16 +68,9 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - const failedResults = results.filter((result) => "error" in result); - if (alertsError) throw alertsError; - if (results.length > 0 && failedResults.length === results.length) { - throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel")); - } if (isPending) { - if (displayMode === "mobileSummary") return ; - return (
@@ -87,17 +78,6 @@ export default function BeszelAlertsWidget({ ); } - if (displayMode === "mobileSummary") { - return ( - 0} - /> - ); - } - return ( diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index 715d74e6e..5e3799a90 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -6,14 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, - queryKey: [["widget", "beszel"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 3b6cc612a..dd2f56c54 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -39,7 +39,6 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; -import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary"; interface SizeConfig { iconSize: number; @@ -367,23 +366,7 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick ); }; -export default function BeszelSystemGridWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemGrid">) { - const t = useScopedI18n("widget.beszelSystemGrid"); - - if (displayMode === "mobileSummary") { - return ( - - ); - } - - return ; -} - -function BeszelSystemGridContent({ +export default function BeszelSystemGridWidget({ options, integrationIds, isEditMode, diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index 7fdfb2237..69dc7e2ba 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -6,13 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["widget", "beszel"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index f774f70cd..91dcd7292 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core"; import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react"; @@ -15,20 +15,9 @@ import classes from "./component.module.css"; import type { WidgetComponentProps } from "../definition"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; -import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; -export default function BeszelSystemStatsWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemStats">) { - const t = useScopedI18n("widget.beszelSystemStats"); - - if (displayMode === "mobileSummary") { - return ; - } - - return ; -} - -function BeszelSystemStatsContent({ +export default function BeszelSystemStatsWidget({ options, integrationIds, isEditMode, @@ -36,7 +25,6 @@ function BeszelSystemStatsContent({ boardId, itemId, setOptions, - isReadOnly = false, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); const board = useOptionalBoard(); @@ -53,29 +41,18 @@ function BeszelSystemStatsContent({ () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))), [systemsResult], ); - const [temporarySystemId, setTemporarySystemId] = useState(null); - const [temporaryTimePeriod, setTemporaryTimePeriod] = useState(null); - const configuredSystem = temporarySystemId ?? options.systemId; - const selectedSystem = - isReadOnly && !systems.some((system) => system.value === configuredSystem) - ? (systems[0]?.value ?? "") - : configuredSystem || systems[0]?.value || ""; + const selectedSystem = options.systemId || systems[0]?.value || ""; const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label; const systemExists = systems.some((system) => system.value === selectedSystem); const handleSelectSystem = useCallback( (value: string) => { - if (isReadOnly) { - setTemporarySystemId(value); - return; - } - setOptions({ newOptions: { systemId: value } }); if (hasChangeAccess && boardId && itemId) { saveItemOptions({ boardId, itemId, newOptions: { systemId: value } }); } }, - [setOptions, isReadOnly, hasChangeAccess, boardId, itemId, saveItemOptions], + [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions], ); if (systemsError) throw systemsError; @@ -163,7 +140,7 @@ function BeszelSystemStatsContent({ 600 ? 2 : 1} visibility={{ cpu: options.showCpu, @@ -175,13 +152,7 @@ function BeszelSystemStatsContent({ dockerMemory: options.showDockerMemory, dockerNetwork: options.showDockerNetwork, }} - onSwitchToHistorical={() => { - if (isReadOnly) { - setTemporaryTimePeriod("1h"); - return; - } - setOptions({ newOptions: { timePeriod: "1h" } }); - }} + onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })} /> diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 40baa556d..af2907b25 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -16,13 +16,6 @@ const timePeriodOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", { icon: IconChartAreaLine, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["widget", "beszel"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 305bdffee..5352eb170 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -38,7 +38,6 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; -import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary"; const directionMultiplier: Record = { asc: 1, desc: -1 }; @@ -71,23 +70,7 @@ const getSizeConfig = (width: number): SizeConfig => { }; }; -export default function BeszelSystemTableWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemTable">) { - const t = useScopedI18n("widget.beszelSystemTable"); - - if (displayMode === "mobileSummary") { - return ( - - ); - } - - return ; -} - -function BeszelSystemTableContent({ +export default function BeszelSystemTableWidget({ options, integrationIds, isEditMode, diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index 17bfdc1e0..ba90cb5e9 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -25,13 +25,6 @@ const sortDirectionOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", { icon: IconTable, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["widget", "beszel"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, diff --git a/packages/widgets/src/beszel/_shared/mobile-summary.tsx b/packages/widgets/src/beszel/_shared/mobile-summary.tsx deleted file mode 100644 index 7f1caa6a0..000000000 --- a/packages/widgets/src/beszel/_shared/mobile-summary.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { clientApi } from "@homarr/api/client"; -import { useI18n } from "@homarr/translation/client"; - -import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; -import { useBeszelFilteredSystems } from "./hooks"; - -interface BeszelSystemsMobileSummaryProps { - integrationIds: string[]; - label: string; - statusFilter: string; -} - -export const BeszelSystemsMobileSummary = ({ - integrationIds, - label, - statusFilter, -}: BeszelSystemsMobileSummaryProps) => { - const t = useI18n(); - const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systems = useBeszelFilteredSystems(results, statusFilter); - const failedResults = results.filter((result) => "error" in result); - - if (isPending) return ; - if (error) throw error; - if (results.length > 0 && failedResults.length === results.length) { - throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel")); - } - - const up = systems.filter((system) => system.status === "up").length; - - return ( - 0} - /> - ); -}; diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 685983859..c57b909fd 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -10,8 +10,6 @@ import { BookmarkAddButton } from "./add-button"; export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { icon: IconBookmark, - mobile: { supportsDetailView: true }, - queryKey: [["app"]], createOptions() { return optionsBuilder.from((factory) => ({ title: factory.text(), diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index ccb07e888..84b8650a0 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useParams } from "next/navigation"; -import { Box, useMantineTheme } from "@mantine/core"; +import { Center, Stack, Text, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; import dayjs from "dayjs"; @@ -13,7 +13,6 @@ import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; import { useScopedI18n } from "@homarr/translation/client"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { CalendarDay } from "./calender-day"; import classes from "./component.module.css"; @@ -41,11 +40,9 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, }; - const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input); - const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); + const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !data) throw error; + const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); return ( ); }; @@ -67,18 +63,9 @@ interface CalendarBaseProps { setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; displayMode?: WidgetComponentProps<"calendar">["displayMode"]; - isStale?: boolean; } -const CalendarBase = ({ - isEditMode, - events, - month, - setMonth, - options, - displayMode, - isStale = false, -}: CalendarBaseProps) => { +const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => { const params = useParams(); const locale = params.locale as string; const { firstDayOfWeek } = useSettings(); @@ -96,32 +83,30 @@ const CalendarBase = ({ .filter((event) => event.startDate.getTime() >= Date.now()) .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime())[0]; const link = nextEvent?.links[0]; - const summary = ( - - ); - - if (!link) return summary; return ( - - {summary} - +
+ + + {nextEvent?.title ?? normalizedEvents.length} + + + {nextEvent + ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate) + : t("name")} + + +
); } diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts index 36365abd6..4bd45f57d 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -15,7 +15,6 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar" supportsCompactSummary: true, supportsDetailView: true, }, - queryKey: [["widget", "calendar"]], createOptions() { return optionsBuilder.from((factory) => ({ releaseType: factory.multiSelect({ diff --git a/packages/widgets/src/common/mobile-summary.tsx b/packages/widgets/src/common/mobile-summary.tsx index 4a8af6270..d3f6e9bf1 100644 --- a/packages/widgets/src/common/mobile-summary.tsx +++ b/packages/widgets/src/common/mobile-summary.tsx @@ -1,57 +1,29 @@ -"use client"; - -import { Center, Group, Loader, Stack, Text } from "@mantine/core"; -import { IconAlertTriangle } from "@tabler/icons-react"; - -import { useI18n } from "@homarr/translation/client"; +import { Center, Group, Stack, Text } from "@mantine/core"; interface WidgetMobileSummaryProps { value: number | string; label: string; description?: string; - isStale?: boolean; } -export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) { - const t = useI18n(); - +export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) { return (
- - {value} - - - {label} - + + + {value} + + + {label} + + {description !== undefined && ( - + {description} )} - {isStale && ( - - - - {t("board.mobile.dataWarning")} - - - )}
); } - -export function WidgetMobileLoading() { - const t = useI18n(); - - return ( -
- -
- ); -} diff --git a/packages/widgets/src/coolify/applications-section.tsx b/packages/widgets/src/coolify/applications-section.tsx index e0ae5691f..bbc52c662 100644 --- a/packages/widgets/src/coolify/applications-section.tsx +++ b/packages/widgets/src/coolify/applications-section.tsx @@ -13,10 +13,9 @@ interface ApplicationsSectionProps { applications: CoolifyApplicationWithContext[]; baseUrl: string; isTiny: boolean; - isMobileDetail: boolean; } -export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) { +export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length; @@ -35,14 +34,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDet {applications.length > 0 ? ( {applications.map((app) => ( - + ))} ) : ( diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index c37f66c05..46940f56e 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -10,12 +10,7 @@ import { createWidgetKey } from "./coolify-utils"; import { InstanceCard } from "./instance-card"; import { SingleInstanceLayout } from "./single-instance-layout"; -export default function CoolifyWidget({ - options, - integrationIds, - width, - displayMode, -}: WidgetComponentProps<"coolify">) { +export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) { const t = useScopedI18n("widget.coolify"); if (integrationIds.length === 0) { @@ -26,24 +21,16 @@ export default function CoolifyWidget({ ); } - return ( - - ); + return ; } interface CoolifyContentProps { integrationIds: string[]; options: WidgetComponentProps<"coolify">["options"]; width: number; - isMobileDetail: boolean; } -function CoolifyContent({ integrationIds, options, width, isMobileDetail }: CoolifyContentProps) { +function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) { const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds }); const isTiny = width < 256; @@ -51,15 +38,7 @@ function CoolifyContent({ integrationIds, options, width, isMobileDetail }: Cool const widgetKey = createWidgetKey(integrationIds); if (instancesData.length === 1 && firstInstance) { - return ( - - ); + return ; } return ( @@ -72,7 +51,6 @@ function CoolifyContent({ integrationIds, options, width, isMobileDetail }: Cool options={options} isTiny={isTiny} widgetKey={widgetKey} - isMobileDetail={isMobileDetail} /> ))} diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts index b0e478985..cebf9de05 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -5,8 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("coolify", { icon: IconCloud, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "coolify"]], createOptions() { return optionsBuilder.from((factory) => ({ showServers: factory.switch({ diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx index dca80819c..0f4522dc4 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -18,10 +18,9 @@ interface InstanceCardProps { options: CoolifyOptions; isTiny: boolean; widgetKey: string; - isMobileDetail: boolean; } -export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDetail }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) { const t = useScopedI18n("widget.coolify"); const cardKey = `${widgetKey}-${instance.integrationId}`; const [showIp, setShowIp] = useLocalStorage({ @@ -100,24 +99,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDet isTiny={isTiny} showIp={showIp} onToggleIp={() => setShowIp((prev) => !prev)} - isMobileDetail={isMobileDetail} /> )} {options.showApplications && ( - + )} {options.showServices && ( - + )} diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 787557413..9f6cfe1ae 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -3,8 +3,6 @@ import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react"; -import { useScopedI18n } from "@homarr/translation/client"; - import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils"; interface ResourceRowProps { @@ -23,18 +21,11 @@ interface ResourceRowProps { baseUrl: string; isTiny: boolean; resourceType: "application" | "service"; - isMobileDetail: boolean; } -export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetail }: ResourceRowProps) { - const tCommon = useScopedI18n("common"); - const tApp = useScopedI18n("widget.app"); +export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) { const status = parseStatus(item.status ?? ""); const statusColor = getStatusColor(status); - const fqdn = cleanFqdn(item.fqdn); - const actionIconSize = isMobileDetail ? 44 : "xs"; - const resourceLabel = resourceType === "application" ? tCommon("applications") : tCommon("services"); - const openInNewTabLabel = tApp("option.openInNewTab.label"); const resourceUrl = item.projectUuid && item.environmentUuid @@ -58,42 +49,18 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetai )} - {fqdn && ( - + {cleanFqdn(item.fqdn) && ( + )} {resourceUrl && ( - + )} {logsUrl && ( - + )} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index b8935ae72..543df1c67 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -15,7 +15,6 @@ interface ServersSectionProps { isTiny: boolean; showIp: boolean; onToggleIp: () => void; - isMobileDetail: boolean; } export function ServersSection({ @@ -25,7 +24,6 @@ export function ServersSection({ isTiny, showIp, onToggleIp, - isMobileDetail, }: ServersSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); @@ -42,11 +40,9 @@ export function ServersSection({ { event.stopPropagation(); onToggleIp(); @@ -69,7 +65,6 @@ export function ServersSection({ baseUrl={baseUrl} isTiny={isTiny} showIp={showIp} - isMobileDetail={isMobileDetail} /> ))} @@ -89,12 +84,10 @@ interface ServerRowProps { baseUrl: string; isTiny: boolean; showIp: boolean; - isMobileDetail: boolean; } -function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: ServerRowProps) { +function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) { const t = useScopedI18n("widget.coolify"); - const tApp = useScopedI18n("widget.app"); const isBuildServer = server.settings?.is_build_server === true; const isOnline = server.is_reachable !== false; const serverUrl = `${baseUrl}/server/${server.uuid}`; @@ -117,15 +110,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: )} - + diff --git a/packages/widgets/src/coolify/services-section.tsx b/packages/widgets/src/coolify/services-section.tsx index 9a44c8f38..c225db27d 100644 --- a/packages/widgets/src/coolify/services-section.tsx +++ b/packages/widgets/src/coolify/services-section.tsx @@ -13,10 +13,9 @@ interface ServicesSectionProps { services: CoolifyServiceWithContext[]; baseUrl: string; isTiny: boolean; - isMobileDetail: boolean; } -export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: ServicesSectionProps) { +export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length; @@ -35,14 +34,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: S {services.length > 0 ? ( {services.map((service) => ( - + ))} ) : ( diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx index 4c0dfd33e..ae44b998f 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -18,16 +18,9 @@ interface SingleInstanceLayoutProps { options: CoolifyOptions; isTiny: boolean; widgetKey: string; - isMobileDetail: boolean; } -export function SingleInstanceLayout({ - instance, - options, - isTiny, - widgetKey, - isMobileDetail, -}: SingleInstanceLayoutProps) { +export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) { const t = useScopedI18n("widget.coolify"); const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${widgetKey}`, @@ -72,24 +65,13 @@ export function SingleInstanceLayout({ isTiny={isTiny} showIp={showIp} onToggleIp={() => setShowIp((prev) => !prev)} - isMobileDetail={isMobileDetail} /> )} {options.showApplications && ( - + )} {options.showServices && ( - + )} diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx index 03ff64fdc..ea89301d8 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -309,21 +309,14 @@ function openJsonInBrowser(json: unknown) { } function RawDisplay({ data }: { data: Record }) { - const tApp = useScopedI18n("widget.app"); const maxHeight = (data.maxHeight as number) ?? 300; const jsonString = JSON.stringify(data.data, null, 2); - const openJsonLabel = `${tApp("option.openInNewTab.label")}: JSON`; return ( - - openJsonInBrowser(data.data)} - > + + openJsonInBrowser(data.data)}> diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts index 90ee7bef7..2bb9d3867 100644 --- a/packages/widgets/src/custom-api/index.ts +++ b/packages/widgets/src/custom-api/index.ts @@ -6,8 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("customApi", { icon: IconApi, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "customApi"]], createOptions() { return optionsBuilder.from((factory) => ({ definitionId: factory.customWidgetSelect({ defaultValue: "" }), diff --git a/packages/widgets/src/custom-api/jsx-interactive-components.tsx b/packages/widgets/src/custom-api/jsx-interactive-components.tsx index 1fe2d2cff..2b18342a3 100644 --- a/packages/widgets/src/custom-api/jsx-interactive-components.tsx +++ b/packages/widgets/src/custom-api/jsx-interactive-components.tsx @@ -2,19 +2,15 @@ import type { ReactNode } from "react"; import { Children, isValidElement, useEffect, useRef, useState } from "react"; -import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core"; +import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core"; import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react"; -import { useScopedI18n } from "@homarr/translation/client"; - interface PaginatedListProps { children: ReactNode; pageSize?: number; } export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { - const tCommon = useScopedI18n("common"); - const { dir } = useDirection(); const items = Children.toArray(children); const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const [page, setPage] = useState(0); @@ -30,8 +26,6 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { const clampedPage = Math.min(page, totalPages - 1); const start = clampedPage * pageSize; const visible = items.slice(start, start + pageSize); - const PreviousIcon = dir === "rtl" ? IconChevronRight : IconChevronLeft; - const NextIcon = dir === "rtl" ? IconChevronLeft : IconChevronRight; return ( @@ -40,24 +34,22 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { setPage((p) => Math.max(0, p - 1))} > - + {clampedPage + 1} / {totalPages} = totalPages - 1} - aria-label={tCommon("action.next")} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} > - + )} diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index bf6723c90..b5900267d 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -15,18 +15,16 @@ import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptions export type WidgetDisplayMode = "default" | "mobileSummary" | "mobileDetail"; export interface WidgetMobilePresentation { - width?: 1 | 2; - height?: 1 | 2 | 3; + width: 1 | 2; + height: 1 | 2 | 3; supportsCompactSummary?: boolean; supportsDetailView?: boolean; eager?: boolean; - unmountWhenOffscreen?: boolean; } export interface WidgetContextMenuAction { key: string; label: stringOrTranslation; - mobileVisible?: boolean; icon?: TablerIcon; onClick: () => void; hidden?: boolean; @@ -113,7 +111,6 @@ export type WidgetComponentProps = WidgetProps boardId: string | undefined; // undefined when in preview mode displayMode?: WidgetDisplayMode; isEditMode: boolean; - isReadOnly?: boolean; setOptions: ({ newOptions }: { newOptions: Partial>> }) => void; width: number; height: number; diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx index 3aa68a9b1..0a04779fa 100644 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ b/packages/widgets/src/dns-hole/controls/TimerModal.tsx @@ -42,21 +42,12 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer setMinutes(0); }} title={t("widget.dnsHoleControls.controls.setTimer")} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} > {t("widget.dnsHoleControls.controls.hours")} - hoursHandlers.current?.decrement()} - > + hoursHandlers.current?.decrement()}> – - hoursHandlers.current?.increment()} - > + hoursHandlers.current?.increment()}> + {t("widget.dnsHoleControls.controls.minutes")} - minutesHandlers.current?.decrement()} - > + minutesHandlers.current?.decrement()}> – - minutesHandlers.current?.increment()} - > + minutesHandlers.current?.increment()}> + @@ -117,7 +91,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer variant="light" color="red" leftSection={} - mih={44} + h="2rem" w="12rem" onClick={handleSetTimer} > diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 4696dfca5..f46c3d746 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -301,7 +301,6 @@ const ControlsCard: React.FC = ({ size="sm" color="green" variant="light" - aria-label={`${t("widget.dnsHoleControls.controls.enable")}: ${data.integration.name}`} > @@ -312,7 +311,6 @@ const ControlsCard: React.FC = ({ size="sm" color="red" variant="light" - aria-label={`${t("widget.dnsHoleControls.controls.disable")}: ${data.integration.name}`} > @@ -325,7 +323,6 @@ const ControlsCard: React.FC = ({ size="sm" color="yellow" variant="light" - aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`} > @@ -384,7 +381,6 @@ const ControlsCard: React.FC = ({ bd={0} ms={"auto"} variant="subtle" - aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`} onClick={() => { setSelectedIntegrationIds([data.integration.id]); open(); diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 553116564..9340a642a 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -9,7 +9,6 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { icon: IconDeviceGamepad, - mobile: { supportsDetailView: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 444cd93d5..e56cd5cf7 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -16,7 +16,6 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useScopedI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; const ContainerStateBadge = ({ state }: { state: ContainerState }) => { @@ -36,7 +35,6 @@ const actionIconIconStyle: IconProps["style"] = { const createColumns = ( t: ReturnType>, - actionIconSize: "xs" | 44, ): MRT_ColumnDef[] => [ { id: "name", @@ -132,7 +130,6 @@ const createColumns = ( const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation({ onSettled }); const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation({ onSettled }); const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation({ onSettled }); - const toggleAction = row.original.state === "running" ? "stop" : "start"; const handleActionAsync = async (action: "start" | "stop" | "restart") => { const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer; @@ -158,13 +155,12 @@ const createColumns = ( return ( - + handleActionAsync(toggleAction)} + onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")} > {row.original.state === "running" ? ( @@ -174,13 +170,7 @@ const createColumns = ( - handleActionAsync("restart")} - > + handleActionAsync("restart")}> @@ -190,38 +180,12 @@ const createColumns = ( }, ]; -export default function DockerWidget({ displayMode, ...props }: WidgetComponentProps<"dockerContainers">) { - if (displayMode === "mobileSummary") return ; - return ; -} - -const DockerMobileSummary = () => { - const t = useScopedI18n("docker"); - const { data, error, isPending } = clientApi.docker.getContainers.useQuery(); - - if (isPending) return ; - if (error && !data) throw error; - - const containers = data?.containers ?? []; - const runningContainers = containers.filter((container) => container.state === "running").length; - - return ( - - ); -}; - -const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponentProps<"dockerContainers">) => { +export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) { const t = useScopedI18n("docker"); const isTiny = width <= 256; - const actionIconSize = displayMode === "mobileDetail" ? 44 : "xs"; const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery(); - const containers = useMemo(() => data?.containers ?? [], [data?.containers]); + const containers = data?.containers ?? []; const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); @@ -238,7 +202,7 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen ); }, [containers]); - const columns = useMemo(() => createColumns(t, actionIconSize), [t, actionIconSize]); + const columns = useMemo(() => createColumns(t), [t]); const columnVisibility: MRT_VisibilityState = { name: options.columns.includes("name"), @@ -336,7 +300,7 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen ); -}; +} diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 7aa41ecc4..4a988c779 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -25,13 +25,6 @@ const columnTranslationKeyMap = { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { icon: IconBrandDocker, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["docker", "getContainers"]], createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 5daba3085..88a7e43fa 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -47,9 +47,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads height: 1, supportsCompactSummary: true, supportsDetailView: true, - unmountWhenOffscreen: true, }, - queryKey: [["widget", "downloads"]], refetchInterval: 5, createOptions() { return optionsBuilder.from( diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index da05f89df..e154fd3ac 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -7,8 +7,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("firewall", { icon: IconWall, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "firewall"]], refetchInterval: 5, createOptions() { return optionsBuilder.from(() => ({})); diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index 8b9a6a8f5..ead4fef92 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -8,7 +8,6 @@ import { clientApi } from "@homarr/api/client"; import type { IntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { ClusterHealthMonitoring } from "./cluster/cluster-health"; import { SystemHealthMonitoring } from "./system-health"; @@ -19,22 +18,11 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) => integration.kind === "proxmox" || integration.kind === "mock"; export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) { - const { data: integrations = [], error, isPending } = clientApi.integration.byIds.useQuery(props.integrationIds); + const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds); const t = useI18n(); - if (props.displayMode === "mobileSummary" && isPending) return ; - if (props.displayMode === "mobileSummary" && error && integrations.length === 0) throw error; - const clusterIntegrationId = integrations.find(isClusterIntegration)?.id; - if (props.displayMode === "mobileSummary") { - return clusterIntegrationId ? ( - - ) : ( - - ); - } - if (!clusterIntegrationId) { return ; } @@ -47,79 +35,24 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal return ; } - const tabs = ( - - - - {t("widget.healthMonitoring.tab.system")} - - - {t("widget.healthMonitoring.tab.cluster")} - - - - - - - - - + return ( + + + + + {t("widget.healthMonitoring.tab.system")} + + + {t("widget.healthMonitoring.tab.cluster")} + + + + + + + + + + ); - - if (props.displayMode === "mobileDetail") return tabs; - - return {tabs}; } - -const SystemHealthMobileSummary = ({ integrationIds, isStale }: { integrationIds: string[]; isStale: boolean }) => { - const t = useI18n(); - const { data, error, isPending } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ - integrationIds, - }); - - if (isPending) return ; - if (error && !data) throw error; - - const healthInfo = data?.[0]?.healthInfo; - if (!healthInfo) return ; - - const totalMemory = healthInfo.memAvailableInBytes + healthInfo.memUsedInBytes; - const memoryPercent = totalMemory > 0 ? Math.round((healthInfo.memUsedInBytes / totalMemory) * 100) : 0; - - return ( - - ); -}; - -const ClusterHealthMobileSummary = ({ integrationId, isStale }: { integrationId: string; isStale: boolean }) => { - const t = useI18n(); - const { data, error, isPending } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ - integrationId, - }); - - if (isPending) return ; - if (error && !data) throw error; - if (!data) return ; - - const runningNodes = data.nodes.filter((node) => node.isRunning); - const totalCpu = runningNodes.reduce((sum, node) => sum + node.cpu.cores, 0); - const usedCpu = runningNodes.reduce((sum, node) => sum + node.cpu.utilization * node.cpu.cores, 0); - const totalMemory = runningNodes.reduce((sum, node) => sum + node.memory.total, 0); - const usedMemory = runningNodes.reduce((sum, node) => sum + node.memory.used, 0); - const cpuPercent = totalCpu > 0 ? Math.round((usedCpu / totalCpu) * 100) : 0; - const memoryPercent = totalMemory > 0 ? Math.round((usedMemory / totalMemory) * 100) : 0; - - return ( - - ); -}; diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index 473425492..ff8eb87ab 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -8,14 +8,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { icon: IconHeartRateMonitor, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, - queryKey: [["widget", "healthMonitoring"]], refetchInterval: 5, createOptions() { return optionsBuilder.from( diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 27a9eab1e..3614a570c 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -83,12 +83,7 @@ export const SystemHealthMonitoring = ({ p="sm" pos="relative" > - + - + diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx index b11672aaa..ac794a631 100644 --- a/packages/widgets/src/iframe/component.tsx +++ b/packages/widgets/src/iframe/component.tsx @@ -6,11 +6,10 @@ import { IconBrowserOff, IconProtocol } from "@tabler/icons-react"; import { objectEntries } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; -import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; -export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) { +export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) { const t = useI18n(); const { embedUrl, allowScrolling, ...permissions } = options; const allowedPermissions = getAllowedPermissions(permissions); @@ -20,11 +19,6 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge if (!isSupportedProtocol(embedUrl)) { return ; } - const frameLabel = getFrameLabel(embedUrl); - - if (displayMode === "mobileSummary") { - return ; - } return ( @@ -32,7 +26,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined} className={classes.iframe} src={embedUrl} - title={`${t("widget.iframe.name")}: ${frameLabel}`} + title="widget iframe" allow={allowedPermissions} scrolling={allowScrolling ? "yes" : "no"} sandbox={sandboxFlags.join(" ")} @@ -45,14 +39,6 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge const supportedProtocols = ["http", "https"]; -const getFrameLabel = (url: string) => { - try { - return new URL(url).hostname; - } catch { - return url; - } -}; - const isSupportedProtocol = (url: string) => { try { const parsedUrl = new URL(url); diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts index c193014d6..d4313aebe 100644 --- a/packages/widgets/src/iframe/index.ts +++ b/packages/widgets/src/iframe/index.ts @@ -5,12 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("iframe", { icon: IconBrowser, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - }, createOptions() { return optionsBuilder.from((factory) => ({ embedUrl: factory.text(), diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index 54d429a7d..e515cd697 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -7,8 +7,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { icon: IconReportSearch, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "indexerManager"]], refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index 2bb3b482c..d2201072e 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -23,7 +23,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; @@ -37,13 +37,11 @@ export default function MediaMissingWidget({ }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const pageSize = Number(options.pageSize); - const { data, error, isPending } = clientApi.widget.mediaOrganizer.getData.useQuery( + const { data } = clientApi.widget.mediaOrganizer.getData.useQuery( { integrationIds, pageSize }, { staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false }, ); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !data) throw error; if (!data) return ; if (data.length === 0) throw new NoIntegrationDataError(); if (!options.showMissing && !options.showQueued) @@ -66,14 +64,7 @@ export default function MediaMissingWidget({ : { value: queuedCount, label: t("tab.queued") }; const description = options.showMissing && options.showQueued ? `${queuedCount} ${t("tab.queued")}` : undefined; - return ( - - ); + return ; } const isThin = width > 0 && width < 160; diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts index 88a853e37..98209d43c 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -15,7 +15,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss supportsCompactSummary: true, supportsDetailView: true, }, - queryKey: [["widget", "mediaOrganizer"]], createOptions() { return optionsBuilder.from((factory) => ({ showMissing: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 948f7f987..4f243c77a 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -15,36 +15,23 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; +import { WidgetMobileSummary } from "../common/mobile-summary"; export default function MediaReleasesWidget({ options, integrationIds, displayMode, }: WidgetComponentProps<"mediaReleases">) { - const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ + const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds, }); const t = useI18n("widget.mediaReleases"); if (displayMode === "mobileSummary") { - if (isPending) return ; - if (error && !data) throw error; - - const releases = data ?? []; - return ( - - ); + return ; } - const releases = data ?? []; - return ( {releases.map((item, index) => ( diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 6ec502dec..865a35123 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -26,7 +26,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; +import { WidgetMobileSummary } from "../../common/mobile-summary"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "../search-button.module.css"; @@ -39,11 +39,7 @@ export default function MediaServerWidget({ displayMode, }: WidgetComponentProps<"mediaRequests-requestList">) { const t = useScopedI18n("widget.mediaRequests-requestList"); - const { - data: mediaRequests, - error, - isPending, - } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ + const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ integrationIds, statuses: options.statusFilter.length > 0 @@ -52,20 +48,11 @@ export default function MediaServerWidget({ recentDays: options.recentDays, }); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !mediaRequests) throw error; if (!mediaRequests) return ; if (mediaRequests.length === 0) throw new NoIntegrationDataError(); if (displayMode === "mobileSummary") { - return ( - - ); + return ; } return ( @@ -244,8 +231,7 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => className="mediaRequests-list-item-pending-button-approve" variant="light" color="green" - size={44} - aria-label={t("pending.approve")} + size="xs" onClick={() => { handleDecision("approve"); }} @@ -258,8 +244,7 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => className="mediaRequests-list-item-pending-button-decline" variant="light" color="red" - size={44} - aria-label={t("pending.decline")} + size="xs" onClick={() => { handleDecision("decline"); }} diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index 9dae1ea5c..c8824bebc 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -17,7 +17,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ supportsCompactSummary: true, supportsDetailView: true, }, - queryKey: [["widget", "mediaRequests"]], createOptions() { return optionsBuilder.from((factory) => ({ linksTargetNewTab: factory.switch({ @@ -39,7 +38,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ contextActions: ({ integrationIds }) => [ { key: "search", - mobileVisible: true, label: (t) => t("search.mode.media.action.search.label"), icon: IconSearch, onClick: () => { diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index d215eabac..d49cb8495 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -7,15 +7,12 @@ import { createWidgetDefinition } from "../../definition"; export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { icon: IconChartBar, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "mediaRequests"]], createOptions() { return {}; }, contextActions: ({ integrationIds }) => [ { key: "search", - mobileVisible: true, label: (t) => t("search.mode.media.action.search.label"), icon: IconSearch, onClick: () => { diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 361b8ab7b..439857a8f 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { Fragment, useCallback, useMemo } from "react"; +import { Fragment, useMemo } from "react"; import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; import { IconDeviceTv, @@ -24,47 +24,9 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; -export default function MediaServerWidget({ displayMode, ...props }: WidgetComponentProps<"mediaServer">) { - if (displayMode === "mobileSummary") return ; - return ; -} - -const MediaServerMobileSummary = ({ - options, - integrationIds, -}: Pick, "options" | "integrationIds">) => { - const t = useScopedI18n("widget.mediaServer"); - const { data, error, isPending } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ - integrationIds, - showOnlyPlaying: options.showOnlyPlaying, - }); - - if (isPending) return ; - if (error && !data) throw error; - - const currentStreams = data ?? []; - const hasPartialFailure = currentStreams.length < integrationIds.length; - const playingSessions = currentStreams - .flatMap((stream) => stream.sessions) - .filter((session) => session.currentlyPlaying !== null); - - return ( - - ); -}; - -const MediaServerTable = ({ - options, - integrationIds, -}: Pick, "options" | "integrationIds">) => { +export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) { const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, showOnlyPlaying: options.showOnlyPlaying, @@ -115,9 +77,7 @@ const MediaServerTable = ({ ? Math.min(100, Math.round((positionMs / durationMs) * 100)) : null; const remainingMinutes = - positionMs !== null && durationMs !== null - ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) - : null; + positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; return ( @@ -164,8 +124,8 @@ const MediaServerTable = ({ const isTranscoding = Boolean( currentlyPlaying.metadata?.transcoding.target.videoCodec ?? - currentlyPlaying.metadata?.transcoding.target.audioCodec ?? - currentlyPlaying.metadata?.transcoding.container, + currentlyPlaying.metadata?.transcoding.target.audioCodec ?? + currentlyPlaying.metadata?.transcoding.container, ); return ( @@ -205,17 +165,6 @@ const MediaServerTable = ({ ); const { openModal } = useModalAction(ItemInfoModal); - const openSession = useCallback( - (session: StreamSession) => { - openModal( - { item: session }, - { - title: session.sessionName, - }, - ); - }, - [openModal], - ); const table = useTranslatedMantineReactTable({ columns, data: flatSessions, @@ -261,21 +210,17 @@ const MediaServerTable = ({ height: "100%", }, }, - mantineTableBodyRowProps: ({ row }) => ({ - tabIndex: 0, - "aria-haspopup": "dialog", - "aria-label": row.original.currentlyPlaying - ? `${row.original.sessionName}: ${row.original.currentlyPlaying.name}` - : row.original.sessionName, - onClick: () => openSession(row.original), - onKeyDown: (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - openSession(row.original); + mantineTableBodyCellProps: ({ row }) => ({ + onClick: () => { + openModal( + { + item: row.original, + }, + { + title: row.original.sessionName, + }, + ); }, - style: { cursor: "pointer" }, - }), - mantineTableBodyCellProps: () => ({ py: 4, style: { overflowX: "hidden", @@ -285,7 +230,7 @@ const MediaServerTable = ({ }); const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => { - const session = flatSessions.find((candidate) => candidate.integrationKind === kind); + const session = flatSessions.find((session) => session.integrationKind === kind); return { integrationKind: kind, integrationIcon: session?.integrationIcon, @@ -317,7 +262,7 @@ const MediaServerTable = ({ ); -}; +} const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { const t = useScopedI18n("widget.mediaServer.items"); diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 34414e3b0..54f00476c 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -7,14 +7,6 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { icon: IconVideo, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, - queryKey: [["widget", "mediaServer"]], refetchInterval: 5, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index 9fe017346..51962c00b 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -10,7 +10,7 @@ import type { TablerIcon } from "@homarr/ui"; import { views } from "."; import { WidgetEmptyState } from "../common/empty-state"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; +import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; @@ -40,13 +40,11 @@ export default function MediaTranscodingWidget({ pageSize: queuePageSize, page: queuePage, }; - const { data: transcodingData, error, isPending } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input); + const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input); const [view, setView] = useState(options.defaultView); const t = useI18n("widget.mediaTranscoding"); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !transcodingData) throw error; if (!transcodingData) return ; if (displayMode === "mobileSummary") { @@ -66,7 +64,6 @@ export default function MediaTranscodingWidget({ value={summary.value} label={summary.label} description={t(failedHealthChecks > 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")} - isStale={Boolean(error)} /> ); } diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts index 8add1e086..95f0f0388 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -17,7 +17,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran supportsCompactSummary: true, supportsDetailView: true, }, - queryKey: [["widget", "mediaTranscoding"]], refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/notebook/component.tsx b/packages/widgets/src/notebook/component.tsx index e8dc77498..84f3b1085 100644 --- a/packages/widgets/src/notebook/component.tsx +++ b/packages/widgets/src/notebook/component.tsx @@ -2,9 +2,6 @@ import dynamic from "next/dynamic"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), { @@ -12,23 +9,5 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note }); export default function NotebookWidget(props: WidgetComponentProps<"notebook">) { - const t = useScopedI18n("widget.notebook"); - - if (props.displayMode === "mobileSummary") { - const excerpt = props.options.content - .replace(/]*>[\s\S]*?<\/style>/gi, " ") - .replace(/]*>[\s\S]*?<\/script>/gi, " ") - .replace(/<[^>]+>/g, " ") - .replace(/&(?:nbsp|#160);/gi, " ") - .replace(/&(?:amp|#38);/gi, "&") - .replace(/&(?:lt|#60);/gi, "<") - .replace(/&(?:gt|#62);/gi, ">") - .replace(/&(?:quot|#34);/gi, '"') - .replace(/\s+/g, " ") - .trim(); - - return ; - } - return ; } diff --git a/packages/widgets/src/notebook/index.ts b/packages/widgets/src/notebook/index.ts index f4ae6840b..6b85f3e33 100644 --- a/packages/widgets/src/notebook/index.ts +++ b/packages/widgets/src/notebook/index.ts @@ -6,12 +6,6 @@ import { defaultContent } from "./default-content"; export const { definition, componentLoader } = createWidgetDefinition("notebook", { icon: IconNotes, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - }, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 763fd1084..9fdd692fb 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -89,14 +89,7 @@ const controlIconProps = { stroke: 1.5, }; -export function Notebook({ - options, - setOptions, - isEditMode, - isReadOnly = false, - boardId, - itemId, -}: WidgetComponentProps<"notebook">) { +export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(content); @@ -104,7 +97,7 @@ export function Notebook({ const { data: session } = useSession(); const { hasChangeAccess } = constructBoardPermissions(board, session); - const canChange = !isEditMode && !isReadOnly && hasChangeAccess; + const canChange = !isEditMode && hasChangeAccess; const [isEditing, setIsEditing] = useState(false); const { primaryColor } = useMantineTheme(); diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index ea8cbd466..bcdda7f93 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { Avatar, Box, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; +import { Avatar, Box, Card, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; import { IconClock } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -9,7 +9,6 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useTimeAgo } from "@homarr/common"; import { useScopedI18n } from "@homarr/translation/client"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; export default function NotificationsWidget({ @@ -17,11 +16,7 @@ export default function NotificationsWidget({ integrationIds, displayMode, }: WidgetComponentProps<"notifications">) { - const { - data: notificationIntegrations, - error, - isPending, - } = clientApi.widget.notifications.getNotifications.useQuery({ + const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, }); @@ -32,39 +27,38 @@ export default function NotificationsWidget({ const sortedNotifications = useMemo( () => - (notificationIntegrations ?? []) + notificationIntegrations .flatMap((integration) => integration.data) .sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()), [notificationIntegrations], ); if (displayMode === "mobileSummary") { - if (isPending) return ; - if (error && !notificationIntegrations) throw error; - - const notifications = notificationIntegrations ?? []; const latestNotification = sortedNotifications[0]; - const isStale = Boolean(error) || notifications.length < integrationIds.length; - const summary = latestNotification ? ( - - ) : ( - - ); - - if (!latestNotification?.href) return summary; return ( - - {summary} - +
+ {latestNotification ? ( + latestNotification.href ? ( + + + + ) : ( + + ) + ) : ( + + {t("noItems")} + + )} +
); } @@ -119,21 +113,22 @@ export default function NotificationsWidget({ ); } -const NotificationMobileSummary = ({ +const NotificationSummary = ({ notification, - isStale, }: { notification: { title: string | null; body: string; time: Date; }; - isStale: boolean; -}) => { - const timeAgo = useTimeAgo(notification.time, 30000); - - return ; -}; +}) => ( + + + {notification.title ?? notification.body} + + + +); const InfoDisplay = ({ date }: { date: Date }) => { const timeAgo = useTimeAgo(date, 30000); // update every 30sec diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 2555183b3..f7f34fff9 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -13,7 +13,6 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat supportsCompactSummary: true, supportsDetailView: true, }, - queryKey: [["widget", "notifications"]], refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts index 9c535e36d..ef6fd2e5a 100644 --- a/packages/widgets/src/paperless-ngx/index.ts +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -5,7 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { icon: IconFileText, - queryKey: [["widget", "paperlessNgx"]], refetchInterval: null, supportedIntegrations: ["paperlessNgx"], integrationsRequired: true, diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts index 386f98a8a..6f97be83f 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -8,8 +8,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { icon: IconShieldCheck, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "patchmon"]], supportedIntegrations: ["patchmon"], integrationsRequired: true, createOptions() { @@ -190,42 +188,18 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon" useCustomThresholds: { shouldHide: (options) => !options.enableThresholdColors, }, - hostsNeedingUpdatesThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsNeedingUpdatesWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsNeedingUpdatesCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - securityUpdatesThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - securityUpdatesWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - securityUpdatesCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsWithSecurityUpdatesThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsWithSecurityUpdatesWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsWithSecurityUpdatesCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - upToDateHostsThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - upToDateHostsWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - upToDateHostsCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, + hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, totalOutdatedPackagesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages, diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts index 61cf2214b..62fa7ebab 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -14,8 +14,6 @@ const relativeDateSchema = z export const { definition, componentLoader } = createWidgetDefinition("releases", { icon: IconRocket, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "releases"]], refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 37c3400a6..626f87328 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -17,7 +17,7 @@ const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"] return feedEntries; }; -export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) { +export default function RssFeed({ options, width }: WidgetComponentProps<"rssFeed">) { const feedEntries = useLiveFeedEntries({ urls: options.feedUrls, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, @@ -29,55 +29,49 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent const isNarrow = width < 128 * 3; - const feed = ( - - {feedEntries.map((feedEntry) => ( - - {feedEntry.enclosure !== undefined && ( - backdrop - )} - - - {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( - {feedEntry.title} + return ( + + + {feedEntries.map((feedEntry) => ( + + {feedEntry.enclosure !== undefined && ( + backdrop )} - - - {feedEntry.title} - - {!options.hideDescription && feedEntry.description && ( - + + {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( + {feedEntry.title} )} - {feedEntry.published && } - - - - ))} - - ); + + + {feedEntry.title} + + {!options.hideDescription && feedEntry.description && ( + + )} - if (displayMode === "mobileDetail") return feed; - - return ( - - {feed} + {feedEntry.published && } + + + + ))} + ); } diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index 0901fb0da..eee7028cb 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -12,8 +12,6 @@ import { optionsBuilder } from "../options"; */ export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { icon: IconRss, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "rssFeed"]], refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts index 9a21ce118..edb46311c 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -13,7 +13,6 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome supportsCompactSummary: true, eager: true, }, - queryKey: [["widget", "smartHome"]], createOptions() { return optionsBuilder.from((factory) => ({ entityId: factory.text({ diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts index fd4e8194e..14cc5d118 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -5,8 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { icon: IconSpeedboat, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "speedtestTracker"]], refetchInterval: null, createOptions() { return optionsBuilder.from( diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts index 208cd3ca4..ba44f481e 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -13,8 +13,6 @@ const timeIntervalOptions = stockPriceTimeFrames.interval; export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { icon: IconBuildingBank, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "stockPrice"]], refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index 8db1129aa..5f84dd0b4 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -9,18 +9,13 @@ import { formatBytesPair } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { filterStorageVolumes } from "../filter-storage-volumes"; import { NoIntegrationDataError } from "../errors/no-data-integration"; type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; -const getDisplayText = ( - item: { used: string; available: string; percentage: number }, - displayMode: DisplayMode, - formatFree: (value: number) => string, -) => { +const getDisplayText = (item: { used: string; available: string; percentage: number }, displayMode: DisplayMode) => { switch (displayMode) { case "percentage": return `${Math.round(item.percentage)}%`; @@ -34,7 +29,7 @@ const getDisplayText = ( return `${item.used} / ${item.available}`; } case "free": - return formatFree(Math.round(100 - item.percentage)); + return `${Math.round(100 - item.percentage)}% free`; default: return `${Math.round(item.percentage)}%`; } @@ -124,21 +119,12 @@ const SystemDiskCard = ({ ); }; -export default function SystemResources({ - integrationIds, - options, - displayMode: widgetDisplayMode, -}: WidgetComponentProps<"systemDisks">) { - const t = useI18n(); +export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) { const queryInput = { integrationIds }; - const { data, error, isPending } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput); - const hasPartialFailure = (data?.length ?? 0) < integrationIds.length; - const formatFree = (value: number) => t("widget.systemDisks.status.free", { value }); + const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput); - if (widgetDisplayMode === "mobileSummary" && isPending) return ; - if (widgetDisplayMode === "mobileSummary" && error && !data) throw error; + const lastItem = data.at(-1); - const lastItem = data?.at(-1); if (!lastItem) return ; const rawFileSystem = lastItem.healthInfo.fileSystem; @@ -151,36 +137,17 @@ export default function SystemResources({ if (fileSystem.length === 0) return ; - if (widgetDisplayMode === "mobileSummary") { - const disk = fileSystem.toSorted((diskA, diskB) => diskB.percentage - diskA.percentage)[0]; - if (!disk) return ; - - const smartItem = smart.find((item) => item.deviceName === disk.deviceName); - const isHealthy = smartItem?.healthy ?? true; - const temperature = - options.showTemperatureIfAvailable && smartItem?.temperature ? `${smartItem.temperature}°C` : undefined; - - return ( - - ); - } - return ( {fileSystem.map((item) => { - const smartItem = smart.find((smartEntry) => smartEntry.deviceName === item.deviceName); + const smartItem = smart.find((smart) => smart.deviceName === item.deviceName); return ( ) { - if (displayMode === "mobileSummary") { - return ; - } - - return ; -} - -const SystemResourcesMobileSummary = ({ - integrationIds, - options, -}: Pick, "integrationIds" | "options">) => { - const t = useScopedI18n("widget.systemResources"); - const { data, error, isPending } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ - integrationIds, - }); - - if (isPending) return ; - if (error && !data) throw error; - - const healthInfo = data?.[0]?.healthInfo; - const hasPartialFailure = (data?.length ?? 0) < integrationIds.length; - if (!healthInfo) return ; - - const memoryCapacityInBytes = healthInfo.memAvailableInBytes + healthInfo.memUsedInBytes; - const memoryUsagePercent = - memoryCapacityInBytes > 0 ? Math.round((healthInfo.memUsedInBytes / memoryCapacityInBytes) * 100) : 0; - const gpuUsage = - healthInfo.gpu.length > 0 - ? healthInfo.gpu.reduce((total, gpu) => total + gpu.processorUtilization, 0) / healthInfo.gpu.length - : 0; - const metrics: Record< - (typeof options.visibleCharts)[number], - { value: string; label: string; description?: string } - > = { - cpu: { - value: `${Math.round(healthInfo.cpuUtilization)}%`, - label: t("card.cpu"), - }, - memory: { - value: `${memoryUsagePercent}%`, - label: t("card.memory"), - }, - gpu: { - value: `${Math.round(gpuUsage)}%`, - label: t("card.gpu"), - }, - network: { - value: healthInfo.network ? `↓ ${formatByteRate(Math.round(healthInfo.network.down))}` : "—", - label: t("card.network"), - description: healthInfo.network ? `↑ ${formatByteRate(Math.round(healthInfo.network.up))}` : undefined, - }, - }; - const [primaryMetric, supportingMetric] = options.visibleCharts.map((chart) => metrics[chart]); - - if (!primaryMetric) return ; - - return ( - - ); -}; - -const SystemResourcesCharts = ({ - integrationIds, - options, -}: Pick, "integrationIds" | "options">) => { +export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) { const { ref, width } = useElementSize(); const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds, }); - const currentHealthInfo = data[0]?.healthInfo; const memoryCapacityInBytes = - (currentHealthInfo?.memAvailableInBytes ?? 0) + (currentHealthInfo?.memUsedInBytes ?? 0); + (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0); const [items, setItems] = useState< { cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[] @@ -203,4 +124,4 @@ const SystemResourcesCharts = ({ ))} ); -}; +} diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index b2a04ac71..b35bab42f 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -14,13 +14,6 @@ const labelDisplayModeOptions = { export const { definition, componentLoader } = createWidgetDefinition("systemResources", { icon: IconGraphFilled, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["widget", "healthMonitoring"]], refetchInterval: 5, supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], diff --git a/packages/widgets/src/timetable/index.ts b/packages/widgets/src/timetable/index.ts index 8189f9fc6..cee44323c 100644 --- a/packages/widgets/src/timetable/index.ts +++ b/packages/widgets/src/timetable/index.ts @@ -8,8 +8,6 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("timetable", { icon: IconBusStop, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "timetable"]], createOptions() { return optionsBuilder.from((factory) => ({ baseUrl: factory.text({ diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index c3bb857ce..9c34eb548 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -5,8 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("tracearr", { icon: IconActivityHeartbeat, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "tracearr"]], refetchInterval: 5, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts index 16034c039..854075121 100644 --- a/packages/widgets/src/traefik/index.ts +++ b/packages/widgets/src/traefik/index.ts @@ -5,8 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("traefik", { icon: IconRoute, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "traefik"]], createOptions() { return optionsBuilder.from((factory) => ({ showTcp: factory.switch({ diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts index 974a3088f..badbef369 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -8,8 +8,6 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth export const { definition, componentLoader } = createWidgetDefinition("umami", { icon: IconChartBar, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "umami"]], refetchInterval: null, supportedIntegrations: ["umami"], createOptions() { diff --git a/packages/widgets/src/ups/index.ts b/packages/widgets/src/ups/index.ts index 3094060f7..681daf417 100644 --- a/packages/widgets/src/ups/index.ts +++ b/packages/widgets/src/ups/index.ts @@ -7,8 +7,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("ups", { icon: IconBatteryCharging, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "ups"]], createOptions() { return optionsBuilder.from((factory) => ({ showBattery: factory.switch({ diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 78b66ef11..5719f2f13 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -10,7 +10,6 @@ import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; @@ -86,41 +85,24 @@ const heroVariantByRing = { false: classes.heroTextOnly, } as const; -export default function UptimeKumaWidget({ - integrationIds, - options, - width, - displayMode, -}: WidgetComponentProps<"uptimeKuma">) { +export default function UptimeKumaWidget({ integrationIds, options, width }: WidgetComponentProps<"uptimeKuma">) { if (integrationIds.length === 0) { throw new NoIntegrationDataError(); } - return ( - - ); + return ; } interface UptimeKumaContentProps { integrationIds: string[]; options: WidgetComponentProps<"uptimeKuma">["options"]; width: number; - displayMode?: WidgetComponentProps<"uptimeKuma">["displayMode"]; } -function UptimeKumaContent({ integrationIds, options, width, displayMode }: UptimeKumaContentProps) { +function UptimeKumaContent({ integrationIds, options, width }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const { - data: dashboardData, - error, - isPending, - } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ - integrationIds, - }); - const hasPartialFailure = (dashboardData?.length ?? 0) < integrationIds.length; + const { data: dashboardData } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }); - if (displayMode === "mobileSummary" && isPending) return ; - if (displayMode === "mobileSummary" && error && !dashboardData) throw error; if (!dashboardData) return ; const combined = dashboardData.reduce( @@ -150,38 +132,6 @@ function UptimeKumaContent({ integrationIds, options, width, displayMode }: Upti const showHero = options.showAverageUptime; const hasContent = showHero || visibleStatKeys.length > 0; - if (displayMode === "mobileSummary") { - const primaryStat = visibleStatKeys[0]; - const supportingStat = showHero ? primaryStat : visibleStatKeys[1]; - const supportingDescription = supportingStat - ? `${formatNumber(combined[supportingStat], 0)} ${t(`stats.${supportingStat}`)}` - : undefined; - - if (!showHero && primaryStat) { - return ( - - ); - } - - if (!hasContent) { - return ; - } - - return ( - - ); - } - const heroLayoutClass = heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing]; diff --git a/packages/widgets/src/uptime-kuma/index.ts b/packages/widgets/src/uptime-kuma/index.ts index 68cf8371c..b9eebcd8c 100644 --- a/packages/widgets/src/uptime-kuma/index.ts +++ b/packages/widgets/src/uptime-kuma/index.ts @@ -5,14 +5,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("uptimeKuma", { icon: IconHeartbeat, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, - queryKey: [["widget", "uptimeKuma"]], createOptions() { return optionsBuilder.from((factory) => ({ showAverageUptime: factory.switch({ diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts index 8085fdaa1..b3cbe8c73 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -7,8 +7,6 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("vpn", { icon: IconShieldLock, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "vpn"]], refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({})); diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 366fc6827..0329597b1 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Group, Popover, Stack, Text, UnstyledButton } from "@mantine/core"; +import { Box, Group, HoverCard, Stack, Text } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; import combineClasses from "clsx"; import dayjs from "dayjs"; @@ -53,24 +53,16 @@ const DailyWeather = ({ options, weather }: WeatherProps) => { return ( <> - - - + + + - - - +
+ + - - + + {getPreferredUnit( weather.current.temperature, @@ -140,8 +132,6 @@ const DailyWeather = ({ options, weather }: WeatherProps) => { }; const WeeklyForecast = ({ options, weather }: WeatherProps) => { - const t = useScopedI18n("widget.weather"); - return ( <> @@ -154,24 +144,16 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => { )} - - - + + + - - - +
+ + - - + + {getPreferredUnit( weather.current.temperature, @@ -188,38 +170,28 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => { function Forecast({ weather, options }: WeatherProps) { const dateFormat = options.dateFormat; - const t = useScopedI18n("widget.weather"); return ( {weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => ( - - - + + - - {dayjs(dayWeather.time).format("dd")} - - - {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} - - - - - + {dayjs(dayWeather.time).format("dd")} + + + {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} + + + + - - + + ))} ); diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index 7b1146217..6c30b6df8 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -7,8 +7,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("weather", { icon: IconCloud, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "weather"]], createOptions() { return optionsBuilder.from( (factory) => ({