From 85db534fa77f90dc6ad0b46faef25d890f71f364 Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 29 Jul 2026 20:07:17 +0200 Subject: [PATCH] Revert "feat: refine mobile dashboard experience" This reverts commit 315520becab2d2a5c692e0244f237ef903629e0c. --- apps/docs/docs/management/boards/index.mdx | 2 +- .../boards/(content)/_prefetch-policy.spec.ts | 17 -- .../boards/(content)/_prefetch-policy.ts | 7 - .../[locale]/boards/_board-switcher-logo.tsx | 20 +- .../board/mobile/deferred-mobile-item.spec.ts | 18 -- .../board/mobile/deferred-mobile-item.tsx | 57 +--- .../board/mobile/mobile-board.module.css | 34 +-- .../components/board/mobile/mobile-board.tsx | 22 +- .../board/mobile/mobile-dialog-stack.spec.ts | 35 --- .../board/mobile/mobile-dialog-stack.ts | 47 ---- .../board/mobile/mobile-layout.spec.ts | 30 --- .../components/board/mobile/mobile-layout.ts | 12 +- .../board/mobile/mobile-presentation.spec.ts | 74 +----- .../board/mobile/mobile-presentation.ts | 18 +- .../mobile/mobile-section-navigation.spec.ts | 28 -- .../board/mobile/mobile-section-navigation.ts | 13 - .../board/mobile/mobile-widget-actions.tsx | 119 +++------ e2e/mobile-board.spec.ts | 243 ------------------ packages/api/src/settle-integrations.spec.ts | 43 ---- .../src/jsx/interactive-components.tsx | 18 +- packages/widgets/src/bazarr/index.ts | 1 - .../src/beszel/_shared/mobile-summary.tsx | 44 ---- .../widgets/src/common/mobile-summary.tsx | 50 +--- .../src/coolify/applications-section.tsx | 12 +- packages/widgets/src/coolify/index.ts | 2 - packages/widgets/src/custom-api/index.ts | 2 - .../src/dns-hole/controls/TimerModal.tsx | 36 +-- .../widgets/src/dns-hole/controls/index.ts | 1 - packages/widgets/src/docker/index.ts | 7 - packages/widgets/src/notebook/component.tsx | 21 -- packages/widgets/src/notebook/index.ts | 6 - packages/widgets/src/paperless-ngx/index.ts | 1 - packages/widgets/src/patchmon/index.ts | 50 +--- packages/widgets/src/system-disks/index.ts | 7 - packages/widgets/src/vpn/index.ts | 2 - 35 files changed, 110 insertions(+), 989 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts delete mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts delete mode 100644 apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts delete mode 100644 apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts delete mode 100644 e2e/mobile-board.spec.ts delete mode 100644 packages/api/src/settle-integrations.spec.ts delete mode 100644 packages/widgets/src/beszel/_shared/mobile-summary.tsx diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 13e1955ec..ef408e221 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -44,7 +44,7 @@ The main canvas and enabled rails stay at least one viewport tall, and the canva ### 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. +This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts deleted file mode 100644 index 448119508..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; - -describe("shouldPrefetchWidgetForRequest", () => { - test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => { - expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false); - }); - - test("keeps downloads prefetched for desktop requests", () => { - expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true); - }); - - test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => { - expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true); - }); -}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts deleted file mode 100644 index c5a89a754..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { WidgetKind } from "@homarr/definitions"; - -export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => { - const isMobileDevice = deviceType === "mobile" || deviceType === "tablet"; - - return kind !== "downloads" || !isMobileDevice; -}; diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx index 7b4551ff5..f28a04489 100644 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -8,22 +8,12 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useIsMobileBoard } from "~/components/board/use-mobile-board"; -import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; +import { BoardLogo } from "~/components/layout/logo/board-logo"; export const BoardSwitcherLogo = () => { const board = useRequiredBoard(); + const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const t = useI18n(); - const isMobileBoard = useIsMobileBoard(); - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isMobileBoard }); - - if (!isMobileBoard) { - return ( - - - - ); - } return ( @@ -36,7 +26,7 @@ export const BoardSwitcherLogo = () => { > - + {board.name} @@ -56,9 +46,7 @@ export const BoardSwitcherLogo = () => { leftSection={isCurrent ? : } aria-current={isCurrent ? "page" : undefined} > - - {availableBoard.name} - + {availableBoard.name} ); })} diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts deleted file mode 100644 index 956bce38c..000000000 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item"; - -describe("getMobilePreloadRootMargin", () => { - test.each([ - [568, "1136px 0px"], - [844, "1688px 0px"], - [932, "1864px 0px"], - ])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => { - expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected); - }); - - test("keeps the preload policy explicit and prevents negative margins", () => { - expect(mobilePreloadViewportMultiplier).toBe(2); - expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px"); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx index de0fa9d98..ea142a886 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -8,25 +8,14 @@ import classes from "./mobile-board.module.css"; interface DeferredMobileItemProps extends PropsWithChildren { eager: boolean; - unmountWhenOffscreen: boolean; } -export const mobilePreloadViewportMultiplier = 2; - -export const getMobilePreloadRootMargin = (viewportHeight: number) => - `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; - -export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => { +export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => { const [hasMounted, setHasMounted] = useState(eager); const wrapperRef = useRef(null); - const hasMountedOnceRef = useRef(eager); useEffect(() => { - if (eager) { - hasMountedOnceRef.current = true; - setHasMounted(true); - return; - } + if (eager || hasMounted) return; const element = wrapperRef.current; if (!element || typeof IntersectionObserver === "undefined") { @@ -34,37 +23,17 @@ export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: De return; } - let observer: IntersectionObserver | null = null; - const observe = () => { - if (hasMountedOnceRef.current && !unmountWhenOffscreen) return; - observer?.disconnect(); - observer = new IntersectionObserver( - ([entry]) => { - const isNearViewport = entry?.isIntersecting ?? false; - - if (isNearViewport) { - hasMountedOnceRef.current = true; - setHasMounted(true); - if (!unmountWhenOffscreen) observer?.disconnect(); - return; - } - - if (unmountWhenOffscreen && !element.contains(document.activeElement)) { - setHasMounted(false); - } - }, - { rootMargin: getMobilePreloadRootMargin(window.innerHeight) }, - ); - observer.observe(element); - }; - - observe(); - window.addEventListener("resize", observe); - return () => { - window.removeEventListener("resize", observe); - observer?.disconnect(); - }; - }, [eager, unmountWhenOffscreen]); + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + setHasMounted(true); + observer.disconnect(); + }, + { rootMargin: "200% 0px" }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [eager, hasMounted]); return (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index 78026b2f1..fae316cc7 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -6,18 +6,6 @@ overflow-x: clip; } -.boardTitle { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - .grid { display: grid; grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); @@ -30,6 +18,8 @@ position: relative; min-width: 0; min-height: 0; + content-visibility: auto; + contain-intrinsic-size: auto 12rem; } .deferredItem { @@ -44,18 +34,9 @@ } .sectionHeading { - width: fit-content; - max-width: 100%; scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md)); - padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); - color: var(--mantine-color-text); - background: var(--mantine-color-body); - border: 1px solid var(--mantine-color-default-border); - border-radius: var(--mantine-radius-md); - box-shadow: var(--mantine-shadow-xs); + padding-block: var(--mantine-spacing-xs); outline-offset: 4px; - overflow-wrap: anywhere; - text-wrap: pretty; } .empty { @@ -67,21 +48,14 @@ position: absolute; z-index: 11; top: 4px; - inset-inline-end: 4px; + right: 4px; border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent); box-shadow: var(--mantine-shadow-sm); } .detailContent, .detailContent > :global(.grid-stack-item-content) { - position: relative; width: 100%; height: 100%; min-height: 24rem; } - -.detailContent :global(.mantine-ActionIcon-root), -.detailContent :global(.mantine-Button-root) { - min-width: 44px; - min-height: 44px; -} diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 6f67e977f..918ea6217 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -13,7 +13,6 @@ import { widgetImports } from "@homarr/widgets"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; -import { useBoardPermissions } from "../permissions/client"; import { SectionProvider } from "../sections/section-context"; import { DeferredMobileItem } from "./deferred-mobile-item"; import classes from "./mobile-board.module.css"; @@ -71,16 +70,19 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { data-mobile-board-item={item.id} data-mobile-display-mode={presentation.displayMode} > - + + - ); }; @@ -88,7 +90,6 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { export const MobileBoard = () => { const board = useRequiredBoard(); const t = useI18n(); - const { hasChangeAccess } = useBoardPermissions(board); const desktopLayout = getDesktopLayout(board); const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]); const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]); @@ -109,9 +110,6 @@ export const MobileBoard = () => { }} > - - {board.name} - {elements.length === 0 ? (
@@ -122,7 +120,7 @@ export const MobileBoard = () => { {t("board.mobile.empty.title")} - {t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")} + {t("board.mobile.empty.description")}
@@ -132,12 +130,10 @@ export const MobileBoard = () => { {block.title} diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts deleted file mode 100644 index 374944e99..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { hasOpenDialogOutside } from "./mobile-dialog-stack"; - -describe("hasOpenDialogOutside", () => { - test("detects an open child dialog outside the details portal", () => { - const root = document.createElement("div"); - const details = document.createElement("div"); - const child = document.createElement("div"); - details.setAttribute("role", "dialog"); - child.setAttribute("role", "dialog"); - root.append(details, child); - - expect(hasOpenDialogOutside(details, root)).toBe(true); - }); - - test("ignores hidden and closed dialogs", () => { - const root = document.createElement("div"); - const details = document.createElement("div"); - const hiddenChild = document.createElement("div"); - details.setAttribute("role", "dialog"); - hiddenChild.setAttribute("role", "dialog"); - hiddenChild.setAttribute("aria-hidden", "true"); - root.append(details, hiddenChild); - - expect(hasOpenDialogOutside(details, root)).toBe(false); - }); - - test("does not treat the current details dialog as nested", () => { - const details = document.createElement("div"); - details.setAttribute("role", "dialog"); - - expect(hasOpenDialogOutside(details, details)).toBe(false); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts deleted file mode 100644 index b46eaf9b3..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import type { RefObject } from "react"; -import { useEffect, useState } from "react"; - -const getDialogElement = (element: HTMLElement | null) => { - if (!element) return null; - if (element.matches('[role="dialog"]')) return element; - return element.closest('[role="dialog"]') ?? element.querySelector('[role="dialog"]'); -}; - -const isOpenDialog = (element: Element) => - element.getAttribute("aria-hidden") !== "true" && - element.getAttribute("data-hidden") !== "true" && - element.getAttribute("data-state") !== "closed" && - !element.hasAttribute("hidden"); - -export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => { - const currentDialog = getDialogElement(element); - - return Array.from(root.querySelectorAll('[role="dialog"]')).some( - (dialog) => dialog !== currentDialog && isOpenDialog(dialog), - ); -}; - -export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject) => { - const [hasNestedDialog, setHasNestedDialog] = useState(false); - - useEffect(() => { - if (!opened || typeof MutationObserver === "undefined") return; - - const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current)); - update(); - - const observer = new MutationObserver(update); - observer.observe(document.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"], - }); - - return () => observer.disconnect(); - }, [dialogRef, opened]); - - return opened && hasNestedDialog; -}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index 1a917d8fb..f617694d5 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -147,10 +147,7 @@ describe("createMobileBoardItems", () => { type: "sectionHeading", sectionId: category.id, anchorId: getMobileSectionAnchorId(category.id), - headingLevel: 2, }); - expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 }); - expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 }); }); test("omits headings for empty and unnamed sections while preserving their items", () => { @@ -183,31 +180,4 @@ describe("createMobileBoardItems", () => { expect(result.map((element) => element.type)).toEqual(["item", "item"]); expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]); }); - - test("does not skip heading levels when unnamed sections are flattened", () => { - const board = new BoardMockBuilder().build(); - const desktopLayoutId = board.layouts.at(0)?.id; - if (!desktopLayoutId) throw new Error("Expected a desktop layout"); - - const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build(); - const namedDynamicSection = new DynamicSectionMockBuilder({ - id: "dynamic", - options: { title: "Services", borderColor: "", customCssClasses: [] }, - }) - .addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id }) - .build(); - - board.sections.push(unnamedCategory, namedDynamicSection); - board.items.push( - new ItemMockBuilder({ id: "item" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id }) - .build(), - ); - - expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({ - type: "sectionHeading", - title: "Services", - headingLevel: 2, - }); - }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index f394647d7..1017856a4 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -19,7 +19,6 @@ export type MobileBoardSectionHeading = { sectionId: string; title: string; anchorId: string; - headingLevel: 2 | 3 | 4 | 5 | 6; }; export type MobileBoardElement = SectionItem | MobileBoardSectionHeading; @@ -52,10 +51,7 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -const createSectionHeading = ( - section: CategorySection | DynamicSectionItem, - headingDepth: number, -): MobileBoardSectionHeading | null => { +const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => { const title = (section.kind === "category" ? section.name : section.options.title).trim(); if (title.length === 0) return null; @@ -65,7 +61,6 @@ const createSectionHeading = ( sectionId: section.id, title, anchorId: getMobileSectionAnchorId(section.id), - headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"], }; }; @@ -117,18 +112,16 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) const createSectionElements = ( section: CategorySection | DynamicSectionItem | (typeof rootSections)[number], - headingDepth = 0, ): { elements: MobileBoardElement[]; items: SectionItem[] } => { if (visitedSections.has(section.id)) return { elements: [], items: [] }; const sectionId = section.id; visitedSections.add(sectionId); - const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth); const sectionElements: MobileBoardElement[] = []; const sectionItems: SectionItem[] = []; for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) { if (element.type === "section") { - const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth); + const nestedResult = createSectionElements(element); sectionElements.push(...nestedResult.elements); sectionItems.push(...nestedResult.items); continue; @@ -143,6 +136,7 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) if (sectionItems.length === 0) return { elements: [], items: [] }; + const heading = section.kind === "empty" ? null : createSectionHeading(section); return { elements: heading ? [heading, ...sectionElements] : sectionElements, items: sectionItems, diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index a145bc20b..c2e6c1197 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - isMobileContextActionVisible, - resolveMobileItemPresentation, - shouldRenderMobileWidgetActions, -} from "./mobile-presentation"; +import { resolveMobileItemPresentation } from "./mobile-presentation"; describe("resolveMobileItemPresentation", () => { test("renders apps as eager compact tiles", () => { @@ -13,51 +9,19 @@ describe("resolveMobileItemPresentation", () => { height: 1, eager: true, supportsDetails: false, - unmountWhenOffscreen: false, }); }); - test("defaults widgets to full width without inventing a detail view", () => { + test("defaults widgets to full width and clamps desktop height", () => { expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ width: 2, height: 3, eager: false, - supportsDetails: false, - unmountWhenOffscreen: false, - }); - }); - - test("enables details only when the widget explicitly supports them", () => { - expect( - resolveMobileItemPresentation( - { kind: "calendar", height: 2 }, - { - width: 2, - height: 1, - supportsDetailView: true, - }, - ), - ).toMatchObject({ supportsDetails: true, }); }); - test("keeps automatic footprint defaults when only details are configured", () => { - expect( - resolveMobileItemPresentation( - { kind: "calendar", height: 7 }, - { - supportsDetailView: true, - }, - ), - ).toMatchObject({ - width: 2, - height: 3, - supportsDetails: true, - }); - }); - - test("prefers explicit presentation metadata while eager mounting takes precedence", () => { + test("prefers explicit presentation metadata", () => { expect( resolveMobileItemPresentation( { kind: "clock", height: 3 }, @@ -67,7 +31,6 @@ describe("resolveMobileItemPresentation", () => { supportsCompactSummary: true, supportsDetailView: false, eager: true, - unmountWhenOffscreen: true, }, ), ).toEqual({ @@ -76,37 +39,6 @@ describe("resolveMobileItemPresentation", () => { displayMode: "mobileSummary", supportsDetails: false, eager: true, - unmountWhenOffscreen: false, }); }); }); - -describe("shouldRenderMobileWidgetActions", () => { - test("does not render an action trigger without an explicit capability", () => { - expect( - shouldRenderMobileWidgetActions({ - supportsDetails: false, - supportsRefresh: false, - visibleContextActionCount: 0, - }), - ).toBe(false); - }); - - test.each([ - { supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 }, - { supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 }, - { supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 }, - ])("renders an action trigger for an explicit capability", (capabilities) => { - expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true); - }); -}); - -describe("isMobileContextActionVisible", () => { - test("requires actions to opt into the read-only mobile surface", () => { - const action = { key: "configure", label: "Configure", onClick: () => undefined }; - - expect(isMobileContextActionVisible(action)).toBe(false); - expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true); - expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index 41917e4a1..62dbd65aa 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -1,5 +1,5 @@ import type { WidgetKind } from "@homarr/definitions"; -import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets"; +import type { WidgetMobilePresentation } from "@homarr/widgets"; export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, @@ -8,20 +8,6 @@ export const resolveMobileItemPresentation = ( width: mobile?.width ?? (item.kind === "app" ? 1 : 2), height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), - supportsDetails: mobile?.supportsDetailView === true, + supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app", eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), - unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), }); - -export const shouldRenderMobileWidgetActions = ({ - supportsDetails, - supportsRefresh, - visibleContextActionCount, -}: { - supportsDetails: boolean; - supportsRefresh: boolean; - visibleContextActionCount: number; -}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0; - -export const isMobileContextActionVisible = (action: WidgetContextMenuAction) => - action.mobileVisible === true && !action.hidden; diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts deleted file mode 100644 index 180699fbf..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; - -import { focusMobileBoardSection } from "./mobile-section-navigation"; - -afterEach(() => { - document.body.replaceChildren(); -}); - -describe("focusMobileBoardSection", () => { - test.each([ - [false, "smooth"], - [true, "auto"], - ] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => { - const heading = document.createElement("h2"); - heading.id = "section"; - heading.tabIndex = -1; - heading.scrollIntoView = vi.fn(); - document.body.append(heading); - - expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true); - expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" }); - expect(document.activeElement).toBe(heading); - }); - - test("does nothing when the section no longer exists", () => { - expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts deleted file mode 100644 index ad426b5c5..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts +++ /dev/null @@ -1,13 +0,0 @@ -interface FocusMobileBoardSectionOptions { - anchorId: string; - reduceMotion: boolean; -} - -export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => { - const heading = document.getElementById(anchorId); - if (!heading) return false; - - heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); - heading.focus({ preventScroll: true }); - return true; -}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx index 60f50c234..d05b1ebcf 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject } from "react"; -import { useMemo, useRef } from "react"; +import { useMemo } from "react"; import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; @@ -18,9 +18,8 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; -import { useHasNestedDialog } from "./mobile-dialog-stack"; +import { useItemActions } from "../items/item-actions"; import classes from "./mobile-board.module.css"; -import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation"; interface MobileWidgetActionsProps { item: SectionItem; @@ -31,16 +30,18 @@ interface MobileWidgetActionsProps { export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { const [actionsOpened, actionsDisclosure] = useDisclosure(false); const [detailsOpened, detailsDisclosure] = useDisclosure(false); - const actionTriggerRef = useRef(null); - const detailsDialogRef = useRef(null); - const openDetailsAfterActionsRef = useRef(false); - const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef); const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); + const { updateItemOptions } = useItemActions(); const definition = widgetImports[item.kind].definition; - const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined; + const queryClient = useQueryClient(); + const queryKey = useMemo( + () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]), + [definition, item.kind], + ); + const isFetching = useIsFetching({ queryKey }) > 0; const options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, [item.kind, item.options, settings], @@ -50,32 +51,36 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M return definition.contextActions({ options, - setOptions: () => undefined, + setOptions: (partial: Record) => + updateItemOptions({ + itemId: item.id, + newOptions: { ...options, ...partial }, + }), integrationIds: item.integrationIds, context: { isEditMode, boardId: board.id, itemId: item.id }, widgetStateRef, } as never) as WidgetContextMenuAction[]; - }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]); - const visibleContextActions = contextActions.filter(isMobileContextActionVisible); + }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]); + const visibleContextActions = contextActions.filter((action) => !action.hidden); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); - const hasActions = shouldRenderMobileWidgetActions({ - supportsDetails, - supportsRefresh: queryKey !== undefined, - visibleContextActionCount: visibleContextActions.length, - }); + const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0; if (!hasActions) return null; + const refresh = () => { + void queryClient.refetchQueries({ queryKey, type: "all" }); + actionsDisclosure.close(); + }; + return ( <> @@ -89,21 +94,6 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="auto" padding="md" overlayProps={{ backgroundOpacity: 0.45, blur: 2 }} - styles={{ - content: { - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, - }} - onExitTransitionEnd={() => { - if (!openDetailsAfterActionsRef.current) return; - openDetailsAfterActionsRef.current = false; - detailsDisclosure.open(); - }} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} > {supportsDetails && ( @@ -113,8 +103,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="lg" leftSection={} onClick={() => { - openDetailsAfterActionsRef.current = true; actionsDisclosure.close(); + detailsDisclosure.open(); }} > {t("board.mobile.details")} @@ -140,74 +130,37 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M ); })} - {queryKey && } + actionTriggerRef.current?.focus()} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} styles={{ - content: { - display: "flex", - flexDirection: "column", - height: "100dvh", - paddingTop: "env(safe-area-inset-top)", - paddingLeft: "env(safe-area-inset-left)", - paddingRight: "env(safe-area-inset-right)", - }, body: { - flex: "1 1 auto", - minHeight: 0, + height: "calc(100dvh - 4rem)", overflowY: "auto", paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", }, }} > - + ); }; - -const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => { - const t = useI18n(); - const queryClient = useQueryClient(); - const isFetching = useIsFetching({ queryKey }) > 0; - - return ( - - ); -}; diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts deleted file mode 100644 index ffe381f61..000000000 --- a/e2e/mobile-board.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { chromium, devices, expect } from "@playwright/test"; -import { createId } from "@paralleldrive/cuid2"; -import SuperJSON from "superjson"; -import { describe, test } from "vitest"; - -import { eq } from "@homarr/db"; -import * as sqliteSchema from "@homarr/db/schema/sqlite"; - -import { createHomarrContainer } from "./shared/create-homarr-container"; -import { createSqliteDbFileAsync } from "./shared/e2e-db"; -import type { SqliteDatabase } from "./shared/e2e-db"; -import { seedAdminUserAsync } from "./shared/seed-admin-user"; - -const adminCredentials = { - username: "admin", - password: "Comp(exP4sswOrd", -}; - -const boardName = "mobile-e2e"; - -const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { - const boardId = createId(); - const layoutId = createId(); - const firstSectionId = createId(); - const secondSectionId = createId(); - - await db.insert(sqliteSchema.boards).values({ - id: boardId, - name: boardName, - creatorId: userId, - }); - await db.insert(sqliteSchema.layouts).values({ - id: layoutId, - name: "Desktop", - boardId, - columnCount: 12, - breakpoint: 0, - }); - await db.insert(sqliteSchema.sections).values([ - { - id: firstSectionId, - boardId, - kind: "category", - name: "First section", - xOffset: 0, - yOffset: 0, - }, - { - id: secondSectionId, - boardId, - kind: "category", - name: "Second section", - xOffset: 0, - yOffset: 20, - }, - ]); - - const itemRows = Array.from({ length: 36 }, (_, index) => { - const id = createId(); - return { - id, - boardId, - kind: index === 0 ? ("notebook" as const) : ("clock" as const), - options: SuperJSON.stringify(index === 0 ? { content: "

Mobile notes

" } : {}), - advancedOptions: SuperJSON.stringify({}), - }; - }); - await db.insert(sqliteSchema.items).values(itemRows); - await db.insert(sqliteSchema.itemLayouts).values( - itemRows.map((item, index) => ({ - itemId: item.id, - layoutId, - sectionId: index < 18 ? firstSectionId : secondSectionId, - xOffset: (index % 6) * 2, - yOffset: Math.floor((index % 18) / 6) * 2, - width: 2, - height: 2, - })), - ); - await db - .update(sqliteSchema.users) - .set({ - homeBoardId: boardId, - mobileHomeBoardId: boardId, - completedBoardTour: true, - }) - .where(eq(sqliteSchema.users.id, userId)); -}; - -describe("Automatic mobile board", () => { - test("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => { - const { db, localMountPath } = await createSqliteDbFileAsync(); - const { userId } = await seedAdminUserAsync(db, adminCredentials); - await seedMobileBoardAsync(db, userId); - - const homarrContainer = await createHomarrContainer({ - environment: { - AUTH_PROVIDERS: "credentials", - }, - mounts: { - "/appdata": localMountPath, - }, - }).start(); - const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; - const browser = await chromium.launch(); - const loginContext = await browser.newContext(); - - try { - const loginPage = await loginContext.newPage(); - await loginPage.goto(`${baseUrl}/auth/login`); - await loginPage.getByLabel("Username").fill(adminCredentials.username); - await loginPage.locator("#password").fill(adminCredentials.password); - await loginPage.locator("button[type='submit']").click(); - await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); - const storageState = await loginContext.storageState(); - - const mobileViewports = [ - { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { - name: "phone landscape", - width: 844, - height: 390, - userAgent: devices["iPhone 13"].userAgent, - phone: true, - }, - { - name: "narrow tablet", - width: 768, - height: 1024, - userAgent: devices["iPad (gen 7)"].userAgent, - phone: false, - }, - ]; - - for (const viewport of mobileViewports) { - const context = await browser.newContext({ - storageState, - viewport: { width: viewport.width, height: viewport.height }, - userAgent: viewport.userAgent, - hasTouch: true, - isMobile: viewport.phone, - reducedMotion: viewport.width === 390 ? "reduce" : "no-preference", - }); - const page = await context.newPage(); - await page.goto(`${baseUrl}/boards/${boardName}`); - - await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 }); - await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36); - await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0); - await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible(); - - const layoutMetrics = await page.evaluate(() => { - const grids = Array.from(document.querySelectorAll("[data-mobile-board] > div")); - const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid"); - const items = Array.from(document.querySelectorAll("[data-mobile-board-item]")); - const focusable = Array.from( - document.querySelectorAll( - '[data-mobile-board] a[href], [data-mobile-board] button, [data-mobile-board] input, [data-mobile-board] select, [data-mobile-board] textarea, [data-mobile-board] [tabindex]:not([tabindex="-1"])', - ), - ); - - return { - columns: twoColumnGrids.map( - (grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length, - ), - documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, - overflowingItems: items.filter((item) => item.scrollWidth > item.clientWidth + 1).length, - outOfBoundsControls: focusable.filter((element) => { - const rect = element.getBoundingClientRect(); - return rect.left < -1 || rect.right > window.innerWidth + 1; - }).length, - layoutControls: document.querySelectorAll( - '[data-testid*="layout"], button[aria-label*="resize" i], button[aria-label*="move" i]', - ).length, - }; - }); - - expect( - layoutMetrics.columns.length > 0 && layoutMetrics.columns.every((count) => count === 2), - viewport.name, - ).toBe(true); - expect(layoutMetrics.documentOverflow, viewport.name).toBeLessThanOrEqual(0); - expect(layoutMetrics.overflowingItems, viewport.name).toBe(0); - expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0); - expect(layoutMetrics.layoutControls, viewport.name).toBe(0); - - if (viewport.width === 320) { - await page.evaluate(() => { - document.documentElement.style.fontSize = "200%"; - }); - const zoomOverflow = await page.evaluate(() => ({ - document: document.documentElement.scrollWidth - document.documentElement.clientWidth, - items: Array.from(document.querySelectorAll("[data-mobile-board-item]")).filter( - (item) => item.scrollWidth > item.clientWidth + 1, - ).length, - })); - expect(zoomOverflow.document).toBeLessThanOrEqual(0); - expect(zoomOverflow.items).toBe(0); - } - - if (viewport.width === 390) { - await page.getByRole("button", { name: "More" }).click(); - const moreDrawer = page.getByRole("dialog", { name: "More" }); - await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible(); - await moreDrawer.getByRole("button", { name: "Second section" }).click(); - await expect(page.getByRole("heading", { name: "Second section" })).toBeFocused(); - - const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i }); - await notebookActions.click(); - await page.getByRole("button", { name: "Open widget details" }).click(); - const details = page.getByRole("dialog", { name: "Notebook" }); - await expect(details).toBeVisible(); - await details.getByRole("button", { name: "Close" }).click(); - await expect(notebookActions).toBeFocused(); - - const rtlOverflow = await page.evaluate(() => { - document.documentElement.dir = "rtl"; - return document.documentElement.scrollWidth - document.documentElement.clientWidth; - }); - expect(rtlOverflow).toBeLessThanOrEqual(0); - } - - await context.close(); - } - - const desktopContext = await browser.newContext({ - storageState, - viewport: { width: 800, height: 900 }, - }); - const desktopPage = await desktopContext.newPage(); - await desktopPage.goto(`${baseUrl}/boards/${boardName}`); - await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0); - await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36); - await desktopContext.close(); - } finally { - await loginContext.close(); - await browser.close(); - await homarrContainer.stop(); - } - }, 180_000); -}); diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts deleted file mode 100644 index fe3b460d5..000000000 --- a/packages/api/src/settle-integrations.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it, vi } from "vitest"; - -import { settleIntegrationQueries } from "./settle-integrations"; - -const integrations = [ - { id: "first", name: "First", kind: "mock" }, - { id: "second", name: "Second", kind: "mock" }, -]; - -describe("settleIntegrationQueries", () => { - it("retains successful integration results when another integration fails", async () => { - const results = await settleIntegrationQueries( - integrations, - vi.fn(async (integration) => { - if (integration.id === "first") throw new Error("offline"); - return integration.name; - }), - { throwOnAllFailure: true }, - ); - - expect(results).toEqual(["Second"]); - }); - - it("throws when every integration fails and throwOnAllFailure is enabled", async () => { - await expect( - settleIntegrationQueries( - integrations, - vi.fn(async () => { - throw new Error("offline"); - }), - { throwOnAllFailure: true }, - ), - ).rejects.toThrow("offline"); - }); - - it("does not treat an empty integration list as a failure", async () => { - await expect( - settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }), - ).resolves.toEqual([]); - }); -}); diff --git a/packages/custom-widgets/src/jsx/interactive-components.tsx b/packages/custom-widgets/src/jsx/interactive-components.tsx index 1fe2d2cff..2b18342a3 100644 --- a/packages/custom-widgets/src/jsx/interactive-components.tsx +++ b/packages/custom-widgets/src/jsx/interactive-components.tsx @@ -2,19 +2,15 @@ import type { ReactNode } from "react"; import { Children, isValidElement, useEffect, useRef, useState } from "react"; -import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core"; +import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core"; import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react"; -import { useScopedI18n } from "@homarr/translation/client"; - interface PaginatedListProps { children: ReactNode; pageSize?: number; } export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { - const tCommon = useScopedI18n("common"); - const { dir } = useDirection(); const items = Children.toArray(children); const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const [page, setPage] = useState(0); @@ -30,8 +26,6 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { const clampedPage = Math.min(page, totalPages - 1); const start = clampedPage * pageSize; const visible = items.slice(start, start + pageSize); - const PreviousIcon = dir === "rtl" ? IconChevronRight : IconChevronLeft; - const NextIcon = dir === "rtl" ? IconChevronLeft : IconChevronRight; return ( @@ -40,24 +34,22 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { setPage((p) => Math.max(0, p - 1))} > - + {clampedPage + 1} / {totalPages} = totalPages - 1} - aria-label={tCommon("action.next")} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} > - + )} diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index 7bd79bf76..ff5bb0d85 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { supportsAdvancedFocus: true, icon: IconBadgeCc, - queryKey: [["widget", "bazarr"]], supportedIntegrations: ["bazarr"], integrationsRequired: true, maxIntegrations: 1, diff --git a/packages/widgets/src/beszel/_shared/mobile-summary.tsx b/packages/widgets/src/beszel/_shared/mobile-summary.tsx deleted file mode 100644 index 7f1caa6a0..000000000 --- a/packages/widgets/src/beszel/_shared/mobile-summary.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { clientApi } from "@homarr/api/client"; -import { useI18n } from "@homarr/translation/client"; - -import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; -import { useBeszelFilteredSystems } from "./hooks"; - -interface BeszelSystemsMobileSummaryProps { - integrationIds: string[]; - label: string; - statusFilter: string; -} - -export const BeszelSystemsMobileSummary = ({ - integrationIds, - label, - statusFilter, -}: BeszelSystemsMobileSummaryProps) => { - const t = useI18n(); - const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systems = useBeszelFilteredSystems(results, statusFilter); - const failedResults = results.filter((result) => "error" in result); - - if (isPending) return ; - if (error) throw error; - if (results.length > 0 && failedResults.length === results.length) { - throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel")); - } - - const up = systems.filter((system) => system.status === "up").length; - - return ( - 0} - /> - ); -}; diff --git a/packages/widgets/src/common/mobile-summary.tsx b/packages/widgets/src/common/mobile-summary.tsx index 4a8af6270..d3f6e9bf1 100644 --- a/packages/widgets/src/common/mobile-summary.tsx +++ b/packages/widgets/src/common/mobile-summary.tsx @@ -1,57 +1,29 @@ -"use client"; - -import { Center, Group, Loader, Stack, Text } from "@mantine/core"; -import { IconAlertTriangle } from "@tabler/icons-react"; - -import { useI18n } from "@homarr/translation/client"; +import { Center, Group, Stack, Text } from "@mantine/core"; interface WidgetMobileSummaryProps { value: number | string; label: string; description?: string; - isStale?: boolean; } -export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) { - const t = useI18n(); - +export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) { return (
- - {value} - - - {label} - + + + {value} + + + {label} + + {description !== undefined && ( - + {description} )} - {isStale && ( - - - - {t("board.mobile.dataWarning")} - - - )}
); } - -export function WidgetMobileLoading() { - const t = useI18n(); - - return ( -
- -
- ); -} diff --git a/packages/widgets/src/coolify/applications-section.tsx b/packages/widgets/src/coolify/applications-section.tsx index e0ae5691f..bbc52c662 100644 --- a/packages/widgets/src/coolify/applications-section.tsx +++ b/packages/widgets/src/coolify/applications-section.tsx @@ -13,10 +13,9 @@ interface ApplicationsSectionProps { applications: CoolifyApplicationWithContext[]; baseUrl: string; isTiny: boolean; - isMobileDetail: boolean; } -export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) { +export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length; @@ -35,14 +34,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDet {applications.length > 0 ? ( {applications.map((app) => ( - + ))} ) : ( diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts index f9bf28ede..cfc96bc9a 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -6,8 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("coolify", { supportsAdvancedFocus: true, icon: IconCloud, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "coolify"]], createOptions() { return optionsBuilder.from((factory) => ({ showServers: factory.switch({ diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts index a1fcf531d..5ca4b6f1f 100644 --- a/packages/widgets/src/custom-api/index.ts +++ b/packages/widgets/src/custom-api/index.ts @@ -6,8 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("customApi", { icon: IconApi, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "customApi"]], createOptions() { return optionsBuilder.from((factory) => ({ definitionId: factory.customWidgetSelect({ defaultValue: "" }), diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx index 3aa68a9b1..0a04779fa 100644 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ b/packages/widgets/src/dns-hole/controls/TimerModal.tsx @@ -42,21 +42,12 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer setMinutes(0); }} title={t("widget.dnsHoleControls.controls.setTimer")} - closeButtonProps={{ - "aria-label": t("common.action.close"), - size: 44, - }} > {t("widget.dnsHoleControls.controls.hours")} - hoursHandlers.current?.decrement()} - > + hoursHandlers.current?.decrement()}> – - hoursHandlers.current?.increment()} - > + hoursHandlers.current?.increment()}> + {t("widget.dnsHoleControls.controls.minutes")} - minutesHandlers.current?.decrement()} - > + minutesHandlers.current?.decrement()}> – - minutesHandlers.current?.increment()} - > + minutesHandlers.current?.increment()}> + @@ -117,7 +91,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer variant="light" color="red" leftSection={} - mih={44} + h="2rem" w="12rem" onClick={handleSetTimer} > diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 11d52c999..8e5a3ee02 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -10,7 +10,6 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconDeviceGamepad, - mobile: { supportsDetailView: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 686a3dd02..bc229fed5 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -25,13 +25,6 @@ const columnTranslationKeyMap = { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { icon: IconBrandDocker, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["docker", "getContainers"]], refetchInterval: 30, createOptions() { diff --git a/packages/widgets/src/notebook/component.tsx b/packages/widgets/src/notebook/component.tsx index e8dc77498..84f3b1085 100644 --- a/packages/widgets/src/notebook/component.tsx +++ b/packages/widgets/src/notebook/component.tsx @@ -2,9 +2,6 @@ import dynamic from "next/dynamic"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), { @@ -12,23 +9,5 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note }); export default function NotebookWidget(props: WidgetComponentProps<"notebook">) { - const t = useScopedI18n("widget.notebook"); - - if (props.displayMode === "mobileSummary") { - const excerpt = props.options.content - .replace(/]*>[\s\S]*?<\/style>/gi, " ") - .replace(/]*>[\s\S]*?<\/script>/gi, " ") - .replace(/<[^>]+>/g, " ") - .replace(/&(?:nbsp|#160);/gi, " ") - .replace(/&(?:amp|#38);/gi, "&") - .replace(/&(?:lt|#60);/gi, "<") - .replace(/&(?:gt|#62);/gi, ">") - .replace(/&(?:quot|#34);/gi, '"') - .replace(/\s+/g, " ") - .trim(); - - return ; - } - return ; } diff --git a/packages/widgets/src/notebook/index.ts b/packages/widgets/src/notebook/index.ts index f4ae6840b..6b85f3e33 100644 --- a/packages/widgets/src/notebook/index.ts +++ b/packages/widgets/src/notebook/index.ts @@ -6,12 +6,6 @@ import { defaultContent } from "./default-content"; export const { definition, componentLoader } = createWidgetDefinition("notebook", { icon: IconNotes, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - }, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts index 362e79afa..600ee4652 100644 --- a/packages/widgets/src/paperless-ngx/index.ts +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { supportsAdvancedFocus: false, 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 555279b9b..2bd52e383 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -9,8 +9,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { supportsAdvancedFocus: true, icon: IconShieldCheck, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "patchmon"]], supportedIntegrations: ["patchmon"], integrationsRequired: true, maxIntegrations: 1, @@ -192,42 +190,18 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon" useCustomThresholds: { shouldHide: (options) => !options.enableThresholdColors, }, - hostsNeedingUpdatesThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsNeedingUpdatesWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsNeedingUpdatesCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - securityUpdatesThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - securityUpdatesWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - securityUpdatesCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsWithSecurityUpdatesThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsWithSecurityUpdatesWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - hostsWithSecurityUpdatesCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - upToDateHostsThresholdMode: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - upToDateHostsWarningAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, - upToDateHostsCriticalAt: { - shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, - }, + hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, totalOutdatedPackagesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages, diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts index 4331705cd..d25f23b58 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -7,13 +7,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { supportsAdvancedFocus: true, icon: IconServer2, - mobile: { - width: 2, - height: 1, - supportsCompactSummary: true, - supportsDetailView: true, - unmountWhenOffscreen: true, - }, queryKey: [["widget", "healthMonitoring"]], refetchInterval: 5, supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts index c834da1e8..f8418d86f 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -8,8 +8,6 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("vpn", { supportsAdvancedFocus: false, icon: IconShieldLock, - mobile: { supportsDetailView: true }, - queryKey: [["widget", "vpn"]], refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({}));