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
);
})}
- : }
- onClick={refresh}
- >
- {t("item.menu.label.refresh")}
-
+ {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 (
+ : }
+ onClick={() => {
+ void queryClient.refetchQueries({ queryKey, type: "all" });
+ onComplete();
+ }}
+ >
+ {t("item.menu.label.refresh")}
+
+ );
+};
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(/