diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index d8724f01e..b6cefdac0 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,24 +22,29 @@ 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) @@ -49,7 +54,9 @@ 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) @@ -57,6 +64,7 @@ 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: @@ -65,10 +73,12 @@ 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. @@ -92,41 +102,55 @@ 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. @@ -135,14 +159,16 @@ 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 | @@ -161,18 +187,23 @@ 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: @@ -184,7 +215,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. ::: @@ -194,47 +225,51 @@ 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 82dce0ba3..5ec14adfa 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { headers } from "next/headers"; import { Suspense } from "react"; import { TRPCError } from "@trpc/server"; @@ -14,6 +15,7 @@ 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"; @@ -27,6 +29,7 @@ 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" }); @@ -51,7 +54,8 @@ export const createBoardContentPage = >( const resolvedParams = await params; const queryClient = getQueryClient(); - const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); + const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]); + const deviceType = userAgent(new Headers(requestHeaders)).device.type; const itemsMap = board.items.reduce((acc, item) => { const existing = acc.get(item.kind); @@ -65,17 +69,19 @@ export const createBoardContentPage = >( const [integrations] = await Promise.all([ getIntegrationsWithPermissionsAsync(session), - ...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 }, - ), - ); - }), - ), + ...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 }, + ), + ); + }), + ), ]); 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 46d9dec3b..e6c8093af 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 } from "react"; +import { useCallback, useEffect, useMemo, useRef } 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,6 +47,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { 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"; @@ -268,6 +269,7 @@ 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"), @@ -275,12 +277,16 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { ); const jumpToSection = (anchorId: string) => { + pendingSectionAnchorId.current = anchorId; disclosure.close(); - requestAnimationFrame(() => { - const heading = document.getElementById(anchorId); - heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); - heading?.focus({ preventScroll: true }); - }); + }; + + const focusPendingSection = () => { + const anchorId = pendingSectionAnchorId.current; + pendingSectionAnchorId.current = null; + if (!anchorId) return; + + focusMobileBoardSection({ anchorId, reduceMotion }); }; return ( @@ -296,8 +302,53 @@ 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 new file mode 100644 index 000000000..448119508 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 000000000..c5a89a754 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts @@ -0,0 +1,7 @@ +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 f28a04489..7b4551ff5 100644 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -8,12 +8,22 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { BoardLogo } from "~/components/layout/logo/board-logo"; +import { useIsMobileBoard } from "~/components/board/use-mobile-board"; +import { BoardLogo, BoardLogoWithTitle } 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 ( @@ -26,7 +36,7 @@ export const BoardSwitcherLogo = () => { > - + {board.name} @@ -46,7 +56,9 @@ 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 3499184c8..d217a8ec5 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -25,6 +25,7 @@ interface BoardItemContentProps { item: SectionItem; displayMode?: WidgetDisplayMode; disableContextMenu?: boolean; + isReadOnly?: boolean; widgetStateRef?: MutableRefObject | null>; } @@ -38,6 +39,7 @@ export const BoardItemContent = ({ item, displayMode = "default", disableContextMenu = false, + isReadOnly = false, widgetStateRef: externalWidgetStateRef, }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); @@ -69,6 +71,7 @@ export const BoardItemContent = ({ width={width} height={height} displayMode={displayMode} + isReadOnly={isReadOnly} widgetStateRef={widgetStateRef} /> @@ -87,9 +90,8 @@ export const BoardItemContent = ({ | null>; } -const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); @@ -141,7 +144,7 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) = }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - + {!isReadOnly && } )} @@ -154,21 +157,23 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) = (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - + {!isReadOnly && } + isReadOnly={isReadOnly} + setOptions={(partialNewOptions) => { + if (isReadOnly) return; 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 new file mode 100644 index 000000000..956bce38c --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts @@ -0,0 +1,18 @@ +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 ea142a886..de0fa9d98 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -8,14 +8,25 @@ import classes from "./mobile-board.module.css"; interface DeferredMobileItemProps extends PropsWithChildren { eager: boolean; + unmountWhenOffscreen: boolean; } -export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => { +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) => { const [hasMounted, setHasMounted] = useState(eager); const wrapperRef = useRef(null); + const hasMountedOnceRef = useRef(eager); useEffect(() => { - if (eager || hasMounted) return; + if (eager) { + hasMountedOnceRef.current = true; + setHasMounted(true); + return; + } const element = wrapperRef.current; if (!element || typeof IntersectionObserver === "undefined") { @@ -23,17 +34,37 @@ export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) return; } - const observer = new IntersectionObserver( - ([entry]) => { - if (!entry?.isIntersecting) return; - setHasMounted(true); - observer.disconnect(); - }, - { rootMargin: "200% 0px" }, - ); - observer.observe(element); - return () => observer.disconnect(); - }, [eager, hasMounted]); + 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]); 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 fae316cc7..78026b2f1 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -6,6 +6,18 @@ 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)); @@ -18,8 +30,6 @@ position: relative; min-width: 0; min-height: 0; - content-visibility: auto; - contain-intrinsic-size: auto 12rem; } .deferredItem { @@ -34,9 +44,18 @@ } .sectionHeading { + width: fit-content; + max-width: 100%; scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md)); - padding-block: var(--mantine-spacing-xs); + 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); outline-offset: 4px; + overflow-wrap: anywhere; + text-wrap: pretty; } .empty { @@ -48,14 +67,21 @@ position: absolute; z-index: 11; top: 4px; - right: 4px; + inset-inline-end: 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 918ea6217..6f67e977f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -13,6 +13,7 @@ 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"; @@ -70,19 +71,16 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { data-mobile-board-item={item.id} data-mobile-display-mode={presentation.displayMode} > - + - + ); }; @@ -90,6 +88,7 @@ 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]); @@ -110,6 +109,9 @@ export const MobileBoard = () => { }} > + + {board.name} + {elements.length === 0 ? (
@@ -120,7 +122,7 @@ export const MobileBoard = () => { {t("board.mobile.empty.title")} - {t("board.mobile.empty.description")} + {t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
@@ -130,10 +132,12 @@ 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 new file mode 100644 index 000000000..374944e99 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..b46eaf9b3 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts @@ -0,0 +1,47 @@ +"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 f617694d5..1a917d8fb 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -147,7 +147,10 @@ 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", () => { @@ -180,4 +183,31 @@ 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 1017856a4..f394647d7 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -19,6 +19,7 @@ export type MobileBoardSectionHeading = { sectionId: string; title: string; anchorId: string; + headingLevel: 2 | 3 | 4 | 5 | 6; }; export type MobileBoardElement = SectionItem | MobileBoardSectionHeading; @@ -51,7 +52,10 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => { +const createSectionHeading = ( + section: CategorySection | DynamicSectionItem, + headingDepth: number, +): MobileBoardSectionHeading | null => { const title = (section.kind === "category" ? section.name : section.options.title).trim(); if (title.length === 0) return null; @@ -61,6 +65,7 @@ const createSectionHeading = (section: CategorySection | DynamicSectionItem): Mo sectionId: section.id, title, anchorId: getMobileSectionAnchorId(section.id), + headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"], }; }; @@ -112,16 +117,18 @@ 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); + const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth); sectionElements.push(...nestedResult.elements); sectionItems.push(...nestedResult.items); continue; @@ -136,7 +143,6 @@ 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 c2e6c1197..a145bc20b 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { resolveMobileItemPresentation } from "./mobile-presentation"; +import { + isMobileContextActionVisible, + resolveMobileItemPresentation, + shouldRenderMobileWidgetActions, +} from "./mobile-presentation"; describe("resolveMobileItemPresentation", () => { test("renders apps as eager compact tiles", () => { @@ -9,19 +13,51 @@ describe("resolveMobileItemPresentation", () => { height: 1, eager: true, supportsDetails: false, + unmountWhenOffscreen: false, }); }); - test("defaults widgets to full width and clamps desktop height", () => { + test("defaults widgets to full width without inventing a detail view", () => { 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("prefers explicit presentation metadata", () => { + 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", () => { expect( resolveMobileItemPresentation( { kind: "clock", height: 3 }, @@ -31,6 +67,7 @@ describe("resolveMobileItemPresentation", () => { supportsCompactSummary: true, supportsDetailView: false, eager: true, + unmountWhenOffscreen: true, }, ), ).toEqual({ @@ -39,6 +76,37 @@ 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 62dbd65aa..41917e4a1 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 { WidgetMobilePresentation } from "@homarr/widgets"; +import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets"; export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, @@ -8,6 +8,20 @@ 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 ?? item.kind !== "app", + supportsDetails: mobile?.supportsDetailView === true, 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 new file mode 100644 index 000000000..180699fbf --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 000000000..ad426b5c5 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts @@ -0,0 +1,13 @@ +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 d05b1ebcf..60f50c234 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 } from "react"; +import { useMemo, useRef } 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,8 +18,9 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; -import { useItemActions } from "../items/item-actions"; +import { useHasNestedDialog } from "./mobile-dialog-stack"; import classes from "./mobile-board.module.css"; +import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation"; interface MobileWidgetActionsProps { item: SectionItem; @@ -30,18 +31,16 @@ 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 queryClient = useQueryClient(); - const queryKey = useMemo( - () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]), - [definition, item.kind], - ); - const isFetching = useIsFetching({ queryKey }) > 0; + const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined; const options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, [item.kind, item.options, settings], @@ -51,36 +50,32 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M return definition.contextActions({ options, - setOptions: (partial: Record) => - updateItemOptions({ - itemId: item.id, - newOptions: { ...options, ...partial }, - }), + setOptions: () => undefined, 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, updateItemOptions, widgetStateRef]); - const visibleContextActions = contextActions.filter((action) => !action.hidden); + }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]); + const visibleContextActions = contextActions.filter(isMobileContextActionVisible); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); - const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0; + const hasActions = shouldRenderMobileWidgetActions({ + supportsDetails, + supportsRefresh: queryKey !== undefined, + visibleContextActionCount: visibleContextActions.length, + }); if (!hasActions) return null; - const refresh = () => { - void queryClient.refetchQueries({ queryKey, type: "all" }); - actionsDisclosure.close(); - }; - return ( <> @@ -94,6 +89,21 @@ 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 && ( @@ -103,8 +113,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="lg" leftSection={} onClick={() => { + openDetailsAfterActionsRef.current = true; actionsDisclosure.close(); - detailsDisclosure.open(); }} > {t("board.mobile.details")} @@ -130,37 +140,74 @@ 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: { - height: "calc(100dvh - 4rem)", + flex: "1 1 auto", + minHeight: 0, 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 95ebcf9f0..a17197ba1 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -6,11 +6,15 @@ 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 new file mode 100644 index 000000000..ffe381f61 --- /dev/null +++ b/e2e/mobile-board.spec.ts @@ -0,0 +1,243 @@ +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 a51313972..1993b8231 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1592,6 +1592,9 @@ 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) { @@ -1613,8 +1616,7 @@ const getHomeIdBoardAsync = async ( and( eq(groupMembers.userId, user.id), not(eq(groups.name, everyoneGroup)), - not(isNull(groups[settingKey])), - not(isNull(groups.homeBoardId)), + or(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 479448584..94a46bc31 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -516,6 +516,64 @@ 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"); @@ -1427,9 +1485,12 @@ const expectInputToBeFullBoardWithName = ( }; const createFullBoardAsync = async (db: Database, name: string) => { - await db.insert(users).values({ - id: defaultCreatorId, - }); + await db + .insert(users) + .values({ + id: defaultCreatorId, + }) + .onConflictDoNothing(); 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 729e82634..19437e139 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,16 +1,13 @@ 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({ @@ -30,8 +27,9 @@ export const calendarRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx, input }) => { - const settled = await Promise.allSettled( - ctx.integrations.map(async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); const { data } = await innerHandler.getDataAsync(); @@ -44,25 +42,8 @@ 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 bdce2473f..0bcd1fc55 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -24,14 +24,18 @@ 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, - }; - }); + 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 }, + ); }), pause: protectedProcedure .meta({ diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 1957f459c..28be00d4a 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -27,15 +27,19 @@ 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, - }; - }); + 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 }, + ); }), listStorageVolumes: publicProcedure .meta({ diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index f05a46142..179610e31 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -9,20 +9,24 @@ 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, - }; - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + releases: data, + }; + }, + { throwOnAllFailure: true }, + ); 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 fbd192499..700b1d007 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -25,10 +25,14 @@ 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 }; - }); + 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 }, + ); return results .flatMap(({ data, integration }) => data.map((request) => ({ @@ -57,10 +61,14 @@ 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 }; - }); + 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 }, + ); 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 3e3e64a4f..03324ab62 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -23,11 +23,15 @@ 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 }; - }); + 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 }, + ); }), }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index bae8ba9fd..2788cb503 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -13,19 +13,23 @@ 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, - }; - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, + }; + }, + { throwOnAllFailure: true }, + ); }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 6df14090c..2ec1f1045 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -8,17 +8,21 @@ 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, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }, + { throwOnAllFailure: true }, + ); }), }); diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts new file mode 100644 index 000000000..fe3b460d5 --- /dev/null +++ b/packages/api/src/settle-integrations.spec.ts @@ -0,0 +1,43 @@ +// @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/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index 643a3b949..09278fb97 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -11,6 +11,7 @@ interface IntegrationLike { interface Options { fallback?: (integration: TIntegration, error: unknown) => TResult; + throwOnAllFailure?: boolean; } export async function settleIntegrationQueries( @@ -19,7 +20,7 @@ export async function settleIntegrationQueries, ): Promise { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); - return settled.flatMap((result, index) => { + const results = settled.flatMap((result, index) => { if (result.status === "fulfilled") return [result.value]; const integration = integrations[index]; logger.warn( @@ -32,4 +33,16 @@ export async function settleIntegrationQueries result.status === "rejected"); + if ( + options?.throwOnAllFailure === true && + settled.length > 0 && + settled.every((result) => result.status === "rejected") && + firstFailure?.status === "rejected" + ) { + throw firstFailure.reason; + } + + return results; } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index f9528ae20..bfb275c07 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1853,6 +1853,8 @@ "internalServerError": "Failed to control DNS Hole" }, "controls": { + "enable": "Enable", + "disable": "Disable", "enableAll": "Enable All", "disableAll": "Disable All", "setTimer": "Set Timer", @@ -3859,7 +3861,8 @@ } }, "status": { - "unhealthy": "Unhealthy" + "unhealthy": "Unhealthy", + "free": "{value}% free" } }, "customApi": { @@ -4137,15 +4140,18 @@ }, "board": { "mobile": { - "actions": "Widget actions", + "actions": "Actions for {title}", "details": "Open widget details", "empty": { "title": "This board is empty", - "description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically." + "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." }, "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 a54aada3f..7c72f5e5d 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -5,6 +5,8 @@ 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 56e079a8c..1d9f40dde 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -14,16 +14,21 @@ 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 }: WidgetComponentProps<"app">) { +export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) { const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const { data: app } = clientApi.app.byId.useQuery({ + const { + data: app, + error, + isPending, + } = clientApi.app.byId.useQuery({ id: options.appId, }); useRegisterSpotlightContextResults( @@ -48,10 +53,14 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget [app, options.openInNewTab], ); + if (displayMode === "mobileSummary" && isPending) return ; + if (displayMode === "mobileSummary" && error && !app) throw error; if (!app) return ; - const isTiny = height < 100 || width < 100; - const isColumnLayout = options.layout.startsWith("column"); + const isMobileSummary = displayMode === "mobileSummary"; + const isTiny = !isMobileSummary && (height < 100 || width < 100); + const layout = isMobileSummary ? "column" : options.layout; + const isColumnLayout = layout.startsWith("column"); return ( )} - {options.descriptionDisplayMode === "normal" && ( + {!isMobileSummary && 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 25af0bdaf..70214147f 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -14,6 +14,8 @@ 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 f2e79f570..10b6e46bc 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -5,6 +5,7 @@ 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 3f934423d..708094f8c 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -13,6 +13,7 @@ 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, @@ -35,6 +36,7 @@ export default function BeszelAlertsWidget({ options, integrationIds, isEditMode, + displayMode, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const alertsInput = useMemo( @@ -68,9 +70,16 @@ 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 (
@@ -78,6 +87,17 @@ 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 5e3799a90..715d74e6e 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -6,6 +6,14 @@ 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 51e5d014c..4e1dc26b2 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -39,6 +39,7 @@ 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; @@ -358,7 +359,23 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick ); }; -export default function BeszelSystemGridWidget({ +export default function BeszelSystemGridWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemGrid">) { + const t = useScopedI18n("widget.beszelSystemGrid"); + + if (displayMode === "mobileSummary") { + return ( + + ); + } + + return ; +} + +function BeszelSystemGridContent({ options, integrationIds, isEditMode, diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index 69dc7e2ba..7fdfb2237 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -6,6 +6,13 @@ 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 91dcd7292..f774f70cd 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 } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core"; import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react"; @@ -15,9 +15,20 @@ 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({ +export default function BeszelSystemStatsWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemStats">) { + const t = useScopedI18n("widget.beszelSystemStats"); + + if (displayMode === "mobileSummary") { + return ; + } + + return ; +} + +function BeszelSystemStatsContent({ options, integrationIds, isEditMode, @@ -25,6 +36,7 @@ export default function BeszelSystemStatsWidget({ boardId, itemId, setOptions, + isReadOnly = false, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); const board = useOptionalBoard(); @@ -41,18 +53,29 @@ export default function BeszelSystemStatsWidget({ () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))), [systemsResult], ); - const selectedSystem = options.systemId || systems[0]?.value || ""; + 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 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, hasChangeAccess, boardId, itemId, saveItemOptions], + [setOptions, isReadOnly, hasChangeAccess, boardId, itemId, saveItemOptions], ); if (systemsError) throw systemsError; @@ -140,7 +163,7 @@ export default function BeszelSystemStatsWidget({ 600 ? 2 : 1} visibility={{ cpu: options.showCpu, @@ -152,7 +175,13 @@ export default function BeszelSystemStatsWidget({ dockerMemory: options.showDockerMemory, dockerNetwork: options.showDockerNetwork, }} - onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })} + onSwitchToHistorical={() => { + if (isReadOnly) { + setTemporaryTimePeriod("1h"); + return; + } + 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 af2907b25..40baa556d 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -16,6 +16,13 @@ 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 5352eb170..305bdffee 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -38,6 +38,7 @@ 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 }; @@ -70,7 +71,23 @@ const getSizeConfig = (width: number): SizeConfig => { }; }; -export default function BeszelSystemTableWidget({ +export default function BeszelSystemTableWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemTable">) { + const t = useScopedI18n("widget.beszelSystemTable"); + + if (displayMode === "mobileSummary") { + return ( + + ); + } + + return ; +} + +function BeszelSystemTableContent({ options, integrationIds, isEditMode, diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index ba90cb5e9..17bfdc1e0 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -25,6 +25,13 @@ 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 new file mode 100644 index 000000000..7f1caa6a0 --- /dev/null +++ b/packages/widgets/src/beszel/_shared/mobile-summary.tsx @@ -0,0 +1,44 @@ +"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 c57b909fd..685983859 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -10,6 +10,8 @@ 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 84b8650a0..ccb07e888 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 { Center, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Box, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; import dayjs from "dayjs"; @@ -13,6 +13,7 @@ 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"; @@ -40,10 +41,12 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d releaseType: options.releaseType, showUnmonitored: options.showUnmonitored, }; - const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input); - + const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input); const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); + if (displayMode === "mobileSummary" && isPending) return ; + if (displayMode === "mobileSummary" && error && !data) throw error; + return ( ); }; @@ -63,9 +67,18 @@ interface CalendarBaseProps { setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; displayMode?: WidgetComponentProps<"calendar">["displayMode"]; + isStale?: boolean; } -const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => { +const CalendarBase = ({ + isEditMode, + events, + month, + setMonth, + options, + displayMode, + isStale = false, +}: CalendarBaseProps) => { const params = useParams(); const locale = params.locale as string; const { firstDayOfWeek } = useSettings(); @@ -83,30 +96,32 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod .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 ( -
- - - {nextEvent?.title ?? normalizedEvents.length} - - - {nextEvent - ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate) - : t("name")} - - -
+ + {summary} + ); } diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts index 4bd45f57d..36365abd6 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -15,6 +15,7 @@ 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 d3f6e9bf1..4a8af6270 100644 --- a/packages/widgets/src/common/mobile-summary.tsx +++ b/packages/widgets/src/common/mobile-summary.tsx @@ -1,29 +1,57 @@ -import { Center, Group, Stack, Text } from "@mantine/core"; +"use client"; + +import { Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; interface WidgetMobileSummaryProps { value: number | string; label: string; description?: string; + isStale?: boolean; } -export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) { +export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) { + const t = useI18n(); + 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 bbc52c662..e0ae5691f 100644 --- a/packages/widgets/src/coolify/applications-section.tsx +++ b/packages/widgets/src/coolify/applications-section.tsx @@ -13,9 +13,10 @@ interface ApplicationsSectionProps { applications: CoolifyApplicationWithContext[]; baseUrl: string; isTiny: boolean; + isMobileDetail: boolean; } -export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) { +export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length; @@ -34,7 +35,14 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati {applications.length > 0 ? ( {applications.map((app) => ( - + ))} ) : ( diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 46940f56e..c37f66c05 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils"; import { InstanceCard } from "./instance-card"; import { SingleInstanceLayout } from "./single-instance-layout"; -export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) { +export default function CoolifyWidget({ + options, + integrationIds, + width, + displayMode, +}: WidgetComponentProps<"coolify">) { const t = useScopedI18n("widget.coolify"); if (integrationIds.length === 0) { @@ -21,16 +26,24 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget ); } - return ; + return ( + + ); } interface CoolifyContentProps { integrationIds: string[]; options: WidgetComponentProps<"coolify">["options"]; width: number; + isMobileDetail: boolean; } -function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) { +function CoolifyContent({ integrationIds, options, width, isMobileDetail }: CoolifyContentProps) { const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds }); const isTiny = width < 256; @@ -38,7 +51,15 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) const widgetKey = createWidgetKey(integrationIds); if (instancesData.length === 1 && firstInstance) { - return ; + return ( + + ); } return ( @@ -51,6 +72,7 @@ function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) 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 cebf9de05..b0e478985 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -5,6 +5,8 @@ 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 0f4522dc4..dca80819c 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -18,9 +18,10 @@ interface InstanceCardProps { options: CoolifyOptions; isTiny: boolean; widgetKey: string; + isMobileDetail: boolean; } -export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDetail }: InstanceCardProps) { const t = useScopedI18n("widget.coolify"); const cardKey = `${widgetKey}-${instance.integrationId}`; const [showIp, setShowIp] = useLocalStorage({ @@ -99,13 +100,24 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC 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 9f6cfe1ae..787557413 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -3,6 +3,8 @@ 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 { @@ -21,11 +23,18 @@ interface ResourceRowProps { baseUrl: string; isTiny: boolean; resourceType: "application" | "service"; + isMobileDetail: boolean; } -export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) { +export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetail }: ResourceRowProps) { + const tCommon = useScopedI18n("common"); + const tApp = useScopedI18n("widget.app"); 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 @@ -49,18 +58,42 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow )} - {cleanFqdn(item.fqdn) && ( - + {fqdn && ( + )} {resourceUrl && ( - + )} {logsUrl && ( - + )} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 543df1c67..b8935ae72 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -15,6 +15,7 @@ interface ServersSectionProps { isTiny: boolean; showIp: boolean; onToggleIp: () => void; + isMobileDetail: boolean; } export function ServersSection({ @@ -24,6 +25,7 @@ export function ServersSection({ isTiny, showIp, onToggleIp, + isMobileDetail, }: ServersSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); @@ -40,9 +42,11 @@ export function ServersSection({ { event.stopPropagation(); onToggleIp(); @@ -65,6 +69,7 @@ export function ServersSection({ baseUrl={baseUrl} isTiny={isTiny} showIp={showIp} + isMobileDetail={isMobileDetail} /> ))} @@ -84,10 +89,12 @@ interface ServerRowProps { baseUrl: string; isTiny: boolean; showIp: boolean; + isMobileDetail: boolean; } -function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) { +function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: 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}`; @@ -110,7 +117,15 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) )} - + diff --git a/packages/widgets/src/coolify/services-section.tsx b/packages/widgets/src/coolify/services-section.tsx index c225db27d..9a44c8f38 100644 --- a/packages/widgets/src/coolify/services-section.tsx +++ b/packages/widgets/src/coolify/services-section.tsx @@ -13,9 +13,10 @@ interface ServicesSectionProps { services: CoolifyServiceWithContext[]; baseUrl: string; isTiny: boolean; + isMobileDetail: boolean; } -export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) { +export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: ServicesSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length; @@ -34,7 +35,14 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr {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 ae44b998f..4c0dfd33e 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -18,9 +18,16 @@ interface SingleInstanceLayoutProps { options: CoolifyOptions; isTiny: boolean; widgetKey: string; + isMobileDetail: boolean; } -export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) { +export function SingleInstanceLayout({ + instance, + options, + isTiny, + widgetKey, + isMobileDetail, +}: SingleInstanceLayoutProps) { const t = useScopedI18n("widget.coolify"); const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${widgetKey}`, @@ -65,13 +72,24 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S 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 ea89301d8..03ff64fdc 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -309,14 +309,21 @@ 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 2bb9d3867..90ee7bef7 100644 --- a/packages/widgets/src/custom-api/index.ts +++ b/packages/widgets/src/custom-api/index.ts @@ -6,6 +6,8 @@ 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 2b18342a3..1fe2d2cff 100644 --- a/packages/widgets/src/custom-api/jsx-interactive-components.tsx +++ b/packages/widgets/src/custom-api/jsx-interactive-components.tsx @@ -2,15 +2,19 @@ import type { ReactNode } from "react"; import { Children, isValidElement, useEffect, useRef, useState } from "react"; -import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core"; +import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } 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); @@ -26,6 +30,8 @@ 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 ( @@ -34,22 +40,24 @@ 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 b5900267d..bf6723c90 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -15,16 +15,18 @@ 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; @@ -111,6 +113,7 @@ 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 0a04779fa..3aa68a9b1 100644 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ b/packages/widgets/src/dns-hole/controls/TimerModal.tsx @@ -42,12 +42,21 @@ 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()} + > + @@ -91,7 +117,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer variant="light" color="red" leftSection={} - h="2rem" + mih={44} 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 f46c3d746..4696dfca5 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -301,6 +301,7 @@ const ControlsCard: React.FC = ({ size="sm" color="green" variant="light" + aria-label={`${t("widget.dnsHoleControls.controls.enable")}: ${data.integration.name}`} > @@ -311,6 +312,7 @@ const ControlsCard: React.FC = ({ size="sm" color="red" variant="light" + aria-label={`${t("widget.dnsHoleControls.controls.disable")}: ${data.integration.name}`} > @@ -323,6 +325,7 @@ const ControlsCard: React.FC = ({ size="sm" color="yellow" variant="light" + aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`} > @@ -381,6 +384,7 @@ 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 9340a642a..553116564 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -9,6 +9,7 @@ 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 e56cd5cf7..444cd93d5 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -16,6 +16,7 @@ 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 }) => { @@ -35,6 +36,7 @@ const actionIconIconStyle: IconProps["style"] = { const createColumns = ( t: ReturnType>, + actionIconSize: "xs" | 44, ): MRT_ColumnDef[] => [ { id: "name", @@ -130,6 +132,7 @@ 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; @@ -155,12 +158,13 @@ const createColumns = ( return ( - + handleActionAsync(row.original.state === "running" ? "stop" : "start")} + aria-label={`${t(`action.${toggleAction}.label`)}: ${row.original.name}`} + onClick={() => handleActionAsync(toggleAction)} > {row.original.state === "running" ? ( @@ -170,7 +174,13 @@ const createColumns = ( - handleActionAsync("restart")}> + handleActionAsync("restart")} + > @@ -180,12 +190,38 @@ const createColumns = ( }, ]; -export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) { +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">) => { const t = useScopedI18n("docker"); const isTiny = width <= 256; + const actionIconSize = displayMode === "mobileDetail" ? 44 : "xs"; const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery(); - const containers = data?.containers ?? []; + const containers = useMemo(() => data?.containers ?? [], [data?.containers]); const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]); const relativeTime = useTimeAgo(timestamp); @@ -202,7 +238,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo ); }, [containers]); - const columns = useMemo(() => createColumns(t), [t]); + const columns = useMemo(() => createColumns(t, actionIconSize), [t, actionIconSize]); const columnVisibility: MRT_VisibilityState = { name: options.columns.includes("name"), @@ -300,7 +336,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo ); -} +}; diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 4a988c779..7aa41ecc4 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -25,6 +25,13 @@ 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/component.tsx b/packages/widgets/src/downloads/component.tsx index 5ca7ab5b6..cb4f0ddf1 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -41,6 +41,7 @@ import relativeTime from "dayjs/plugin/relativeTime"; import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common"; @@ -48,6 +49,7 @@ import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; +import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; dayjs.extend(relativeTime); @@ -78,13 +80,85 @@ const columnsRatios: Record = { upSpeed: 3, }; -export default function DownloadClientsWidget({ +type DownloadsWidgetOptions = WidgetComponentProps<"downloads">["options"]; +type DownloadItem = RouterOutputs["widget"]["downloads"]["getJobsAndStatuses"][number]["data"]["items"][number]; +type DownloadVisibilityOptions = Pick< + DownloadsWidgetOptions, + | "activeTorrentThreshold" + | "categoryFilter" + | "filterIsWhitelist" + | "showCompletedHttp" + | "showCompletedTorrent" + | "showCompletedUsenet" +>; + +const matchesConfiguredFilters = (item: DownloadItem, options: DownloadVisibilityOptions) => { + const matchesCategoryFilter = + options.filterIsWhitelist === + options.categoryFilter.some((filter) => + (Array.isArray(item.category) ? item.category : [item.category]).includes(filter), + ); + const isVisibleCompletedItem = + (item.type === "torrent" && + ((item.progress === 1 && + options.showCompletedTorrent && + (item.upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) || + item.progress !== 1)) || + (item.type === "usenet" && ((item.progress === 1 && options.showCompletedUsenet) || item.progress !== 1)) || + (item.type === "miscellaneous" && ((item.progress === 1 && options.showCompletedHttp) || item.progress !== 1)); + + return matchesCategoryFilter && isVisibleCompletedItem; +}; + +export default function DownloadClientsWidget(props: WidgetComponentProps<"downloads">) { + if (props.displayMode === "mobileSummary") { + return ; + } + + return ; +} + +const DownloadClientsMobileSummary = ({ + integrationIds, + options, +}: Pick, "integrationIds" | "options">) => { + const t = useScopedI18n("widget.downloads"); + const { data, error, isPending } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ + integrationIds, + limitPerIntegration: options.limitPerIntegration, + }); + + if (isPending) return ; + if (error && !data) throw error; + + const currentItems = data ?? []; + const selectedClients = currentItems.filter(({ integration }) => integrationIds.includes(integration.id)); + const visibleItemCount = selectedClients.reduce( + (total, { data: clientData }) => + total + clientData.items.filter((item) => matchesConfiguredFilters(item, options)).length, + 0, + ); + const totalDownloadSpeed = selectedClients.reduce( + (total, { data: clientData }) => total + (clientData.status.rates.down ?? 0), + 0, + ); + + return ( + + ); +}; + +const DownloadClientsTable = ({ isEditMode, integrationIds, options, setOptions, - displayMode, -}: WidgetComponentProps<"downloads">) { +}: WidgetComponentProps<"downloads">) => { const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => integrationIds.includes(id) ? [id] : [], ); @@ -119,6 +193,25 @@ export default function DownloadClientsWidget({ const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads); const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads); + const visibilityOptions = useMemo( + () => ({ + activeTorrentThreshold: options.activeTorrentThreshold, + categoryFilter: options.categoryFilter, + filterIsWhitelist: options.filterIsWhitelist, + showCompletedHttp: options.showCompletedHttp, + showCompletedTorrent: options.showCompletedTorrent, + showCompletedUsenet: options.showCompletedUsenet, + }), + [ + options.activeTorrentThreshold, + options.categoryFilter, + options.filterIsWhitelist, + options.showCompletedHttp, + options.showCompletedTorrent, + options.showCompletedUsenet, + ], + ); + //Flatten Data array for which each element has its integration, data (base + calculated) and actions const data = useMemo( () => @@ -129,24 +222,7 @@ export default function DownloadClientsWidget({ .flatMap((pair) => //Apply user white/black list pair.data.items - .filter( - ({ category }) => - options.filterIsWhitelist === - options.categoryFilter.some((filter) => - (Array.isArray(category) ? category : [category]).includes(filter), - ), - ) - //Filter completed items following widget option - .filter( - ({ type, progress, upSpeed }) => - (type === "torrent" && - ((progress === 1 && - options.showCompletedTorrent && - (upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) || - progress !== 1)) || - (type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) || - (type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)), - ) + .filter((item) => matchesConfiguredFilters(item, visibilityOptions)) //Filter following user quick setting .filter( ({ state }) => @@ -184,13 +260,8 @@ export default function DownloadClientsWidget({ mutateDeleteItem, mutatePauseItem, mutateResumeItem, - options.activeTorrentThreshold, - options.categoryFilter, - options.filterIsWhitelist, - options.showCompletedTorrent, - options.showCompletedUsenet, - options.showCompletedHttp, quickFilters, + visibilityOptions, ], ); @@ -317,12 +388,24 @@ export default function DownloadClientsWidget({ return actions ? ( - + {pausedAction === "resume" ? : } - + @@ -354,7 +437,12 @@ export default function DownloadClientsWidget({ ) : ( - + ); @@ -606,23 +694,6 @@ export default function DownloadClientsWidget({ { up: 0, down: 0 }, ); - if (displayMode === "mobileSummary") { - const totalDownloadSpeed = clients.reduce((total, { status }) => total + (status?.rates.down ?? 0), 0); - - return ( -
- - - {data.length} - - - {`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`} - - -
- ); - } - if (options.columns.length === 0) return (
@@ -657,7 +728,7 @@ export default function DownloadClientsWidget({ ); -} +}; interface ItemInfoModalProps { items: ExtendedDownloadClientItem[]; @@ -770,11 +841,17 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); const [opened, { open, close }] = useDisclosure(false); const t = useScopedI18n("widget.downloads"); + const tCommon = useScopedI18n("common"); return ( - + @@ -819,6 +896,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli radius="lg" disabled={integrationsStatuses.paused.length === 0} variant="light" + aria-label={t("actions.clients.resume")} onClick={() => mutateResumeQueue({ integrationIds: integrationsStatuses.paused })} > @@ -843,6 +921,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli radius="xl" disabled={integrationsStatuses.active.length === 0} variant="light" + aria-label={t("actions.clients.pause")} onClick={() => mutatePauseQueue({ integrationIds: integrationsStatuses.active })} > @@ -900,6 +979,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli radius={999} variant="light" size="lg" + aria-label={`${t(`actions.client.${client.status.paused ? "resume" : "pause"}`)}: ${client.integration.name}`} onClick={() => { (client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({ integrationIds: [client.integration.id], @@ -910,7 +990,13 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: Cli ) : ( - + )} diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 7c876b926..d52dde1d9 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -38,7 +38,9 @@ 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 e154fd3ac..da05f89df 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -7,6 +7,8 @@ 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 ead4fef92..8b9a6a8f5 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -8,6 +8,7 @@ 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"; @@ -18,11 +19,22 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) => integration.kind === "proxmox" || integration.kind === "mock"; export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) { - const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds); + const { data: integrations = [], error, isPending } = 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 ; } @@ -35,24 +47,79 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal return ; } - return ( - - - - - {t("widget.healthMonitoring.tab.system")} - - - {t("widget.healthMonitoring.tab.cluster")} - - - - - - - - - - + const tabs = ( + + + + {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 ff8eb87ab..473425492 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -8,6 +8,14 @@ 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 3614a570c..27a9eab1e 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -83,7 +83,12 @@ export const SystemHealthMonitoring = ({ p="sm" pos="relative" > - + - + diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx index ac794a631..b11672aaa 100644 --- a/packages/widgets/src/iframe/component.tsx +++ b/packages/widgets/src/iframe/component.tsx @@ -6,10 +6,11 @@ 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 }: WidgetComponentProps<"iframe">) { +export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) { const t = useI18n(); const { embedUrl, allowScrolling, ...permissions } = options; const allowedPermissions = getAllowedPermissions(permissions); @@ -19,6 +20,11 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro if (!isSupportedProtocol(embedUrl)) { return ; } + const frameLabel = getFrameLabel(embedUrl); + + if (displayMode === "mobileSummary") { + return ; + } return ( @@ -26,7 +32,7 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined} className={classes.iframe} src={embedUrl} - title="widget iframe" + title={`${t("widget.iframe.name")}: ${frameLabel}`} allow={allowedPermissions} scrolling={allowScrolling ? "yes" : "no"} sandbox={sandboxFlags.join(" ")} @@ -39,6 +45,14 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro 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 d4313aebe..c193014d6 100644 --- a/packages/widgets/src/iframe/index.ts +++ b/packages/widgets/src/iframe/index.ts @@ -5,6 +5,12 @@ 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 e515cd697..54d429a7d 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -7,6 +7,8 @@ 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 d2201072e..2bb3b482c 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 { WidgetMobileSummary } from "../common/mobile-summary"; +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"; @@ -37,11 +37,13 @@ export default function MediaMissingWidget({ }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const pageSize = Number(options.pageSize); - const { data } = clientApi.widget.mediaOrganizer.getData.useQuery( + const { data, error, isPending } = 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) @@ -64,7 +66,14 @@ 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 98209d43c..88a853e37 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -15,6 +15,7 @@ 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 4f243c77a..948f7f987 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -15,23 +15,36 @@ 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: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ + const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds, }); const t = useI18n("widget.mediaReleases"); if (displayMode === "mobileSummary") { - return ; + if (isPending) return ; + if (error && !data) throw error; + + const releases = data ?? []; + 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 865a35123..6ec502dec 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 { WidgetMobileSummary } from "../../common/mobile-summary"; +import { WidgetMobileLoading, 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,7 +39,11 @@ export default function MediaServerWidget({ displayMode, }: WidgetComponentProps<"mediaRequests-requestList">) { const t = useScopedI18n("widget.mediaRequests-requestList"); - const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ + const { + data: mediaRequests, + error, + isPending, + } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ integrationIds, statuses: options.statusFilter.length > 0 @@ -48,11 +52,20 @@ 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 ( @@ -231,7 +244,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => className="mediaRequests-list-item-pending-button-approve" variant="light" color="green" - size="xs" + size={44} + aria-label={t("pending.approve")} onClick={() => { handleDecision("approve"); }} @@ -244,7 +258,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => className="mediaRequests-list-item-pending-button-decline" variant="light" color="red" - size="xs" + size={44} + aria-label={t("pending.decline")} onClick={() => { handleDecision("decline"); }} diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index c8824bebc..9dae1ea5c 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -17,6 +17,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ supportsCompactSummary: true, supportsDetailView: true, }, + queryKey: [["widget", "mediaRequests"]], createOptions() { return optionsBuilder.from((factory) => ({ linksTargetNewTab: factory.switch({ @@ -38,6 +39,7 @@ 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 d49cb8495..d215eabac 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -7,12 +7,15 @@ 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 439857a8f..361b8ab7b 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, useMemo } from "react"; +import { Fragment, useCallback, useMemo } from "react"; import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; import { IconDeviceTv, @@ -24,9 +24,47 @@ 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({ options, integrationIds }: WidgetComponentProps<"mediaServer">) { +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">) => { const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ integrationIds, showOnlyPlaying: options.showOnlyPlaying, @@ -77,7 +115,9 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom ? 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 ( @@ -124,8 +164,8 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom 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 ( @@ -165,6 +205,17 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom ); const { openModal } = useModalAction(ItemInfoModal); + const openSession = useCallback( + (session: StreamSession) => { + openModal( + { item: session }, + { + title: session.sessionName, + }, + ); + }, + [openModal], + ); const table = useTranslatedMantineReactTable({ columns, data: flatSessions, @@ -210,17 +261,21 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom height: "100%", }, }, - mantineTableBodyCellProps: ({ row }) => ({ - onClick: () => { - openModal( - { - item: row.original, - }, - { - title: row.original.sessionName, - }, - ); + 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); }, + style: { cursor: "pointer" }, + }), + mantineTableBodyCellProps: () => ({ py: 4, style: { overflowX: "hidden", @@ -230,7 +285,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom }); const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => { - const session = flatSessions.find((session) => session.integrationKind === kind); + const session = flatSessions.find((candidate) => candidate.integrationKind === kind); return { integrationKind: kind, integrationIcon: session?.integrationIcon, @@ -262,7 +317,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom ); -} +}; 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 54f00476c..34414e3b0 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -7,6 +7,14 @@ 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 51962c00b..9fe017346 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 { WidgetMobileSummary } from "../common/mobile-summary"; +import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; @@ -40,11 +40,13 @@ export default function MediaTranscodingWidget({ pageSize: queuePageSize, page: queuePage, }; - const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input); + const { data: transcodingData, error, isPending } = 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") { @@ -64,6 +66,7 @@ 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 95f0f0388..8add1e086 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -17,6 +17,7 @@ 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 84f3b1085..e8dc77498 100644 --- a/packages/widgets/src/notebook/component.tsx +++ b/packages/widgets/src/notebook/component.tsx @@ -2,6 +2,9 @@ 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), { @@ -9,5 +12,23 @@ 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 6b85f3e33..f4ae6840b 100644 --- a/packages/widgets/src/notebook/index.ts +++ b/packages/widgets/src/notebook/index.ts @@ -6,6 +6,12 @@ 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 9fdd692fb..763fd1084 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -89,7 +89,14 @@ const controlIconProps = { stroke: 1.5, }; -export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) { +export function Notebook({ + options, + setOptions, + isEditMode, + isReadOnly = false, + boardId, + itemId, +}: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(content); @@ -97,7 +104,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W const { data: session } = useSession(); const { hasChangeAccess } = constructBoardPermissions(board, session); - const canChange = !isEditMode && hasChangeAccess; + const canChange = !isEditMode && !isReadOnly && 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 bcdda7f93..ea8cbd466 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, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; +import { Avatar, Box, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; import { IconClock } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -9,6 +9,7 @@ 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({ @@ -16,7 +17,11 @@ export default function NotificationsWidget({ integrationIds, displayMode, }: WidgetComponentProps<"notifications">) { - const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({ + const { + data: notificationIntegrations, + error, + isPending, + } = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, }); @@ -27,38 +32,39 @@ 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 ( -
- {latestNotification ? ( - latestNotification.href ? ( - - - - ) : ( - - ) - ) : ( - - {t("noItems")} - - )} -
+ + {summary} + ); } @@ -113,22 +119,21 @@ export default function NotificationsWidget({ ); } -const NotificationSummary = ({ +const NotificationMobileSummary = ({ notification, + isStale, }: { notification: { title: string | null; body: string; time: Date; }; -}) => ( - - - {notification.title ?? notification.body} - - - -); + isStale: boolean; +}) => { + const timeAgo = useTimeAgo(notification.time, 30000); + + return ; +}; 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 f7f34fff9..2555183b3 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -13,6 +13,7 @@ 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 ef6fd2e5a..9c535e36d 100644 --- a/packages/widgets/src/paperless-ngx/index.ts +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -5,6 +5,7 @@ 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 6f97be83f..386f98a8a 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -8,6 +8,8 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { icon: IconShieldCheck, + mobile: { supportsDetailView: true }, + queryKey: [["widget", "patchmon"]], supportedIntegrations: ["patchmon"], integrationsRequired: true, createOptions() { @@ -188,18 +190,42 @@ 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 62fa7ebab..61cf2214b 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -14,6 +14,8 @@ 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 626f87328..37c3400a6 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 }: WidgetComponentProps<"rssFeed">) { +export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) { const feedEntries = useLiveFeedEntries({ urls: options.feedUrls, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, @@ -29,49 +29,55 @@ export default function RssFeed({ options, width }: WidgetComponentProps<"rssFee const isNarrow = width < 128 * 3; - return ( - - - {feedEntries.map((feedEntry) => ( - - {feedEntry.enclosure !== undefined && ( - backdrop + const feed = ( + + {feedEntries.map((feedEntry) => ( + + {feedEntry.enclosure !== undefined && ( + backdrop + )} + + + {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( + {feedEntry.title} )} - - {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( - {feedEntry.title} + + + {feedEntry.title} + + {!options.hideDescription && feedEntry.description && ( + )} - - - {feedEntry.title} - - {!options.hideDescription && feedEntry.description && ( - - )} + {feedEntry.published && } + + + + ))} + + ); - {feedEntry.published && } - - - - ))} - + if (displayMode === "mobileDetail") return feed; + + return ( + + {feed} ); } diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index eee7028cb..0901fb0da 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -12,6 +12,8 @@ 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 edb46311c..9a21ce118 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -13,6 +13,7 @@ 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 14cc5d118..fd4e8194e 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -5,6 +5,8 @@ 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 ba44f481e..208cd3ca4 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -13,6 +13,8 @@ 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 5f84dd0b4..8db1129aa 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -9,13 +9,18 @@ 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) => { +const getDisplayText = ( + item: { used: string; available: string; percentage: number }, + displayMode: DisplayMode, + formatFree: (value: number) => string, +) => { switch (displayMode) { case "percentage": return `${Math.round(item.percentage)}%`; @@ -29,7 +34,7 @@ const getDisplayText = (item: { used: string; available: string; percentage: num return `${item.used} / ${item.available}`; } case "free": - return `${Math.round(100 - item.percentage)}% free`; + return formatFree(Math.round(100 - item.percentage)); default: return `${Math.round(item.percentage)}%`; } @@ -119,12 +124,21 @@ const SystemDiskCard = ({ ); }; -export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemDisks">) { +export default function SystemResources({ + integrationIds, + options, + displayMode: widgetDisplayMode, +}: WidgetComponentProps<"systemDisks">) { + const t = useI18n(); const queryInput = { integrationIds }; - const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput); + 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 lastItem = data.at(-1); + if (widgetDisplayMode === "mobileSummary" && isPending) return ; + if (widgetDisplayMode === "mobileSummary" && error && !data) throw error; + const lastItem = data?.at(-1); if (!lastItem) return ; const rawFileSystem = lastItem.healthInfo.fileSystem; @@ -137,17 +151,36 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo 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((smart) => smart.deviceName === item.deviceName); + const smartItem = smart.find((smartEntry) => smartEntry.deviceName === item.deviceName); return ( ) { +export default function SystemResources({ + integrationIds, + options, + displayMode, +}: WidgetComponentProps<"systemResources">) { + 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">) => { const { ref, width } = useElementSize(); const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds, }); + const currentHealthInfo = data[0]?.healthInfo; const memoryCapacityInBytes = - (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0); + (currentHealthInfo?.memAvailableInBytes ?? 0) + (currentHealthInfo?.memUsedInBytes ?? 0); const [items, setItems] = useState< { cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[] @@ -124,4 +203,4 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo ))} ); -} +}; diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index b35bab42f..b2a04ac71 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -14,6 +14,13 @@ 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 cee44323c..8189f9fc6 100644 --- a/packages/widgets/src/timetable/index.ts +++ b/packages/widgets/src/timetable/index.ts @@ -8,6 +8,8 @@ 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 9c34eb548..c3bb857ce 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -5,6 +5,8 @@ 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 854075121..16034c039 100644 --- a/packages/widgets/src/traefik/index.ts +++ b/packages/widgets/src/traefik/index.ts @@ -5,6 +5,8 @@ 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 badbef369..974a3088f 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -8,6 +8,8 @@ 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 681daf417..3094060f7 100644 --- a/packages/widgets/src/ups/index.ts +++ b/packages/widgets/src/ups/index.ts @@ -7,6 +7,8 @@ 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 5719f2f13..78b66ef11 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -10,6 +10,7 @@ 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"; @@ -85,24 +86,41 @@ const heroVariantByRing = { false: classes.heroTextOnly, } as const; -export default function UptimeKumaWidget({ integrationIds, options, width }: WidgetComponentProps<"uptimeKuma">) { +export default function UptimeKumaWidget({ + integrationIds, + options, + width, + displayMode, +}: 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 }: UptimeKumaContentProps) { +function UptimeKumaContent({ integrationIds, options, width, displayMode }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const { data: dashboardData } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }); + const { + data: dashboardData, + error, + isPending, + } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ + integrationIds, + }); + const hasPartialFailure = (dashboardData?.length ?? 0) < integrationIds.length; + if (displayMode === "mobileSummary" && isPending) return ; + if (displayMode === "mobileSummary" && error && !dashboardData) throw error; if (!dashboardData) return ; const combined = dashboardData.reduce( @@ -132,6 +150,38 @@ function UptimeKumaContent({ integrationIds, options, width }: UptimeKumaContent 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 b9eebcd8c..68cf8371c 100644 --- a/packages/widgets/src/uptime-kuma/index.ts +++ b/packages/widgets/src/uptime-kuma/index.ts @@ -5,6 +5,14 @@ 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 b3cbe8c73..8085fdaa1 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -7,6 +7,8 @@ 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 0329597b1..366fc6827 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Box, Group, HoverCard, Stack, Text } from "@mantine/core"; +import { Group, Popover, Stack, Text, UnstyledButton } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; import combineClasses from "clsx"; import dayjs from "dayjs"; @@ -53,16 +53,24 @@ const DailyWeather = ({ options, weather }: WeatherProps) => { return ( <> - - - + + + - - - + + + - - + + {getPreferredUnit( weather.current.temperature, @@ -132,6 +140,8 @@ const DailyWeather = ({ options, weather }: WeatherProps) => { }; const WeeklyForecast = ({ options, weather }: WeatherProps) => { + const t = useScopedI18n("widget.weather"); + return ( <> @@ -144,16 +154,24 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => { )} - - - + + + - - - + + + - - + + {getPreferredUnit( weather.current.temperature, @@ -170,28 +188,38 @@ 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 6c30b6df8..7b1146217 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -7,6 +7,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("weather", { icon: IconCloud, + mobile: { supportsDetailView: true }, + queryKey: [["widget", "weather"]], createOptions() { return optionsBuilder.from( (factory) => ({