diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
index 78026b2f1..fae316cc7 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css
@@ -6,18 +6,6 @@
overflow-x: clip;
}
-.boardTitle {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
-
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
@@ -30,6 +18,8 @@
position: relative;
min-width: 0;
min-height: 0;
+ content-visibility: auto;
+ contain-intrinsic-size: auto 12rem;
}
.deferredItem {
@@ -44,18 +34,9 @@
}
.sectionHeading {
- width: fit-content;
- max-width: 100%;
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
- padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
- color: var(--mantine-color-text);
- background: var(--mantine-color-body);
- border: 1px solid var(--mantine-color-default-border);
- border-radius: var(--mantine-radius-md);
- box-shadow: var(--mantine-shadow-xs);
+ padding-block: var(--mantine-spacing-xs);
outline-offset: 4px;
- overflow-wrap: anywhere;
- text-wrap: pretty;
}
.empty {
@@ -67,21 +48,14 @@
position: absolute;
z-index: 11;
top: 4px;
- inset-inline-end: 4px;
+ right: 4px;
border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent);
box-shadow: var(--mantine-shadow-sm);
}
.detailContent,
.detailContent > :global(.grid-stack-item-content) {
- position: relative;
width: 100%;
height: 100%;
min-height: 24rem;
}
-
-.detailContent :global(.mantine-ActionIcon-root),
-.detailContent :global(.mantine-Button-root) {
- min-width: 44px;
- min-height: 44px;
-}
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
index 6f67e977f..918ea6217 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
@@ -13,7 +13,6 @@ import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
-import { useBoardPermissions } from "../permissions/client";
import { SectionProvider } from "../sections/section-context";
import { DeferredMobileItem } from "./deferred-mobile-item";
import classes from "./mobile-board.module.css";
@@ -71,16 +70,19 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
data-mobile-board-item={item.id}
data-mobile-display-mode={presentation.displayMode}
>
-
+
+
-
);
};
@@ -88,7 +90,6 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
export const MobileBoard = () => {
const board = useRequiredBoard();
const t = useI18n();
- const { hasChangeAccess } = useBoardPermissions(board);
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]);
@@ -109,9 +110,6 @@ export const MobileBoard = () => {
}}
>
-
- {board.name}
-
{elements.length === 0 ? (
@@ -122,7 +120,7 @@ export const MobileBoard = () => {
{t("board.mobile.empty.title")}
- {t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
+ {t("board.mobile.empty.description")}
@@ -132,12 +130,10 @@ export const MobileBoard = () => {
{block.title}
diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts
deleted file mode 100644
index 374944e99..000000000
--- a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import { hasOpenDialogOutside } from "./mobile-dialog-stack";
-
-describe("hasOpenDialogOutside", () => {
- test("detects an open child dialog outside the details portal", () => {
- const root = document.createElement("div");
- const details = document.createElement("div");
- const child = document.createElement("div");
- details.setAttribute("role", "dialog");
- child.setAttribute("role", "dialog");
- root.append(details, child);
-
- expect(hasOpenDialogOutside(details, root)).toBe(true);
- });
-
- test("ignores hidden and closed dialogs", () => {
- const root = document.createElement("div");
- const details = document.createElement("div");
- const hiddenChild = document.createElement("div");
- details.setAttribute("role", "dialog");
- hiddenChild.setAttribute("role", "dialog");
- hiddenChild.setAttribute("aria-hidden", "true");
- root.append(details, hiddenChild);
-
- expect(hasOpenDialogOutside(details, root)).toBe(false);
- });
-
- test("does not treat the current details dialog as nested", () => {
- const details = document.createElement("div");
- details.setAttribute("role", "dialog");
-
- expect(hasOpenDialogOutside(details, details)).toBe(false);
- });
-});
diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts
deleted file mode 100644
index b46eaf9b3..000000000
--- a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import type { RefObject } from "react";
-import { useEffect, useState } from "react";
-
-const getDialogElement = (element: HTMLElement | null) => {
- if (!element) return null;
- if (element.matches('[role="dialog"]')) return element;
- return element.closest('[role="dialog"]') ?? element.querySelector('[role="dialog"]');
-};
-
-const isOpenDialog = (element: Element) =>
- element.getAttribute("aria-hidden") !== "true" &&
- element.getAttribute("data-hidden") !== "true" &&
- element.getAttribute("data-state") !== "closed" &&
- !element.hasAttribute("hidden");
-
-export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => {
- const currentDialog = getDialogElement(element);
-
- return Array.from(root.querySelectorAll('[role="dialog"]')).some(
- (dialog) => dialog !== currentDialog && isOpenDialog(dialog),
- );
-};
-
-export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject) => {
- const [hasNestedDialog, setHasNestedDialog] = useState(false);
-
- useEffect(() => {
- if (!opened || typeof MutationObserver === "undefined") return;
-
- const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current));
- update();
-
- const observer = new MutationObserver(update);
- observer.observe(document.body, {
- subtree: true,
- childList: true,
- attributes: true,
- attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"],
- });
-
- return () => observer.disconnect();
- }, [dialogRef, opened]);
-
- return opened && hasNestedDialog;
-};
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
index 1a917d8fb..f617694d5 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts
@@ -147,10 +147,7 @@ describe("createMobileBoardItems", () => {
type: "sectionHeading",
sectionId: category.id,
anchorId: getMobileSectionAnchorId(category.id),
- headingLevel: 2,
});
- expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 });
- expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 });
});
test("omits headings for empty and unnamed sections while preserving their items", () => {
@@ -183,31 +180,4 @@ describe("createMobileBoardItems", () => {
expect(result.map((element) => element.type)).toEqual(["item", "item"]);
expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]);
});
-
- test("does not skip heading levels when unnamed sections are flattened", () => {
- const board = new BoardMockBuilder().build();
- const desktopLayoutId = board.layouts.at(0)?.id;
- if (!desktopLayoutId) throw new Error("Expected a desktop layout");
-
- const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build();
- const namedDynamicSection = new DynamicSectionMockBuilder({
- id: "dynamic",
- options: { title: "Services", borderColor: "", customCssClasses: [] },
- })
- .addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id })
- .build();
-
- board.sections.push(unnamedCategory, namedDynamicSection);
- board.items.push(
- new ItemMockBuilder({ id: "item" })
- .addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id })
- .build(),
- );
-
- expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({
- type: "sectionHeading",
- title: "Services",
- headingLevel: 2,
- });
- });
});
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts
index f394647d7..1017856a4 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts
@@ -19,7 +19,6 @@ export type MobileBoardSectionHeading = {
sectionId: string;
title: string;
anchorId: string;
- headingLevel: 2 | 3 | 4 | 5 | 6;
};
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
@@ -52,10 +51,7 @@ const getLayout = (
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
-const createSectionHeading = (
- section: CategorySection | DynamicSectionItem,
- headingDepth: number,
-): MobileBoardSectionHeading | null => {
+const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => {
const title = (section.kind === "category" ? section.name : section.options.title).trim();
if (title.length === 0) return null;
@@ -65,7 +61,6 @@ const createSectionHeading = (
sectionId: section.id,
title,
anchorId: getMobileSectionAnchorId(section.id),
- headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"],
};
};
@@ -117,18 +112,16 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
const createSectionElements = (
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
- headingDepth = 0,
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
if (visitedSections.has(section.id)) return { elements: [], items: [] };
const sectionId = section.id;
visitedSections.add(sectionId);
- const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth);
const sectionElements: MobileBoardElement[] = [];
const sectionItems: SectionItem[] = [];
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
if (element.type === "section") {
- const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth);
+ const nestedResult = createSectionElements(element);
sectionElements.push(...nestedResult.elements);
sectionItems.push(...nestedResult.items);
continue;
@@ -143,6 +136,7 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
if (sectionItems.length === 0) return { elements: [], items: [] };
+ const heading = section.kind === "empty" ? null : createSectionHeading(section);
return {
elements: heading ? [heading, ...sectionElements] : sectionElements,
items: sectionItems,
diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
index a145bc20b..c2e6c1197 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
@@ -1,10 +1,6 @@
import { describe, expect, test } from "vitest";
-import {
- isMobileContextActionVisible,
- resolveMobileItemPresentation,
- shouldRenderMobileWidgetActions,
-} from "./mobile-presentation";
+import { resolveMobileItemPresentation } from "./mobile-presentation";
describe("resolveMobileItemPresentation", () => {
test("renders apps as eager compact tiles", () => {
@@ -13,51 +9,19 @@ describe("resolveMobileItemPresentation", () => {
height: 1,
eager: true,
supportsDetails: false,
- unmountWhenOffscreen: false,
});
});
- test("defaults widgets to full width without inventing a detail view", () => {
+ test("defaults widgets to full width and clamps desktop height", () => {
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
width: 2,
height: 3,
eager: false,
- supportsDetails: false,
- unmountWhenOffscreen: false,
- });
- });
-
- test("enables details only when the widget explicitly supports them", () => {
- expect(
- resolveMobileItemPresentation(
- { kind: "calendar", height: 2 },
- {
- width: 2,
- height: 1,
- supportsDetailView: true,
- },
- ),
- ).toMatchObject({
supportsDetails: true,
});
});
- test("keeps automatic footprint defaults when only details are configured", () => {
- expect(
- resolveMobileItemPresentation(
- { kind: "calendar", height: 7 },
- {
- supportsDetailView: true,
- },
- ),
- ).toMatchObject({
- width: 2,
- height: 3,
- supportsDetails: true,
- });
- });
-
- test("prefers explicit presentation metadata while eager mounting takes precedence", () => {
+ test("prefers explicit presentation metadata", () => {
expect(
resolveMobileItemPresentation(
{ kind: "clock", height: 3 },
@@ -67,7 +31,6 @@ describe("resolveMobileItemPresentation", () => {
supportsCompactSummary: true,
supportsDetailView: false,
eager: true,
- unmountWhenOffscreen: true,
},
),
).toEqual({
@@ -76,37 +39,6 @@ describe("resolveMobileItemPresentation", () => {
displayMode: "mobileSummary",
supportsDetails: false,
eager: true,
- unmountWhenOffscreen: false,
});
});
});
-
-describe("shouldRenderMobileWidgetActions", () => {
- test("does not render an action trigger without an explicit capability", () => {
- expect(
- shouldRenderMobileWidgetActions({
- supportsDetails: false,
- supportsRefresh: false,
- visibleContextActionCount: 0,
- }),
- ).toBe(false);
- });
-
- test.each([
- { supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 },
- { supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 },
- { supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 },
- ])("renders an action trigger for an explicit capability", (capabilities) => {
- expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true);
- });
-});
-
-describe("isMobileContextActionVisible", () => {
- test("requires actions to opt into the read-only mobile surface", () => {
- const action = { key: "configure", label: "Configure", onClick: () => undefined };
-
- expect(isMobileContextActionVisible(action)).toBe(false);
- expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true);
- expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false);
- });
-});
diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
index 41917e4a1..62dbd65aa 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
@@ -1,5 +1,5 @@
import type { WidgetKind } from "@homarr/definitions";
-import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets";
+import type { WidgetMobilePresentation } from "@homarr/widgets";
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
@@ -8,20 +8,6 @@ export const resolveMobileItemPresentation = (
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))),
displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const),
- supportsDetails: mobile?.supportsDetailView === true,
+ supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app",
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
- unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
});
-
-export const shouldRenderMobileWidgetActions = ({
- supportsDetails,
- supportsRefresh,
- visibleContextActionCount,
-}: {
- supportsDetails: boolean;
- supportsRefresh: boolean;
- visibleContextActionCount: number;
-}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
-
-export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
- action.mobileVisible === true && !action.hidden;
diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts
deleted file mode 100644
index 180699fbf..000000000
--- a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { afterEach, describe, expect, test, vi } from "vitest";
-
-import { focusMobileBoardSection } from "./mobile-section-navigation";
-
-afterEach(() => {
- document.body.replaceChildren();
-});
-
-describe("focusMobileBoardSection", () => {
- test.each([
- [false, "smooth"],
- [true, "auto"],
- ] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => {
- const heading = document.createElement("h2");
- heading.id = "section";
- heading.tabIndex = -1;
- heading.scrollIntoView = vi.fn();
- document.body.append(heading);
-
- expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true);
- expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" });
- expect(document.activeElement).toBe(heading);
- });
-
- test("does nothing when the section no longer exists", () => {
- expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false);
- });
-});
diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts
deleted file mode 100644
index ad426b5c5..000000000
--- a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-interface FocusMobileBoardSectionOptions {
- anchorId: string;
- reduceMotion: boolean;
-}
-
-export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => {
- const heading = document.getElementById(anchorId);
- if (!heading) return false;
-
- heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
- heading.focus({ preventScroll: true });
- return true;
-};
diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx
index 60f50c234..d05b1ebcf 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx
+++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject } from "react";
-import { useMemo, useRef } from "react";
+import { useMemo } from "react";
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
@@ -18,9 +18,8 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
-import { useHasNestedDialog } from "./mobile-dialog-stack";
+import { useItemActions } from "../items/item-actions";
import classes from "./mobile-board.module.css";
-import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
interface MobileWidgetActionsProps {
item: SectionItem;
@@ -31,16 +30,18 @@ interface MobileWidgetActionsProps {
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
- const actionTriggerRef = useRef(null);
- const detailsDialogRef = useRef(null);
- const openDetailsAfterActionsRef = useRef(false);
- const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
+ const { updateItemOptions } = useItemActions();
const definition = widgetImports[item.kind].definition;
- const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined;
+ const queryClient = useQueryClient();
+ const queryKey = useMemo(
+ () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
+ [definition, item.kind],
+ );
+ const isFetching = useIsFetching({ queryKey }) > 0;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record,
[item.kind, item.options, settings],
@@ -50,32 +51,36 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
return definition.contextActions({
options,
- setOptions: () => undefined,
+ setOptions: (partial: Record) =>
+ updateItemOptions({
+ itemId: item.id,
+ newOptions: { ...options, ...partial },
+ }),
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
} as never) as WidgetContextMenuAction[];
- }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]);
- const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
+ }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
+ const visibleContextActions = contextActions.filter((action) => !action.hidden);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
- const hasActions = shouldRenderMobileWidgetActions({
- supportsDetails,
- supportsRefresh: queryKey !== undefined,
- visibleContextActionCount: visibleContextActions.length,
- });
+ const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
if (!hasActions) return null;
+ const refresh = () => {
+ void queryClient.refetchQueries({ queryKey, type: "all" });
+ actionsDisclosure.close();
+ };
+
return (
<>
@@ -89,21 +94,6 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
size="auto"
padding="md"
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
- styles={{
- content: {
- paddingLeft: "env(safe-area-inset-left)",
- paddingRight: "env(safe-area-inset-right)",
- },
- }}
- onExitTransitionEnd={() => {
- if (!openDetailsAfterActionsRef.current) return;
- openDetailsAfterActionsRef.current = false;
- detailsDisclosure.open();
- }}
- closeButtonProps={{
- "aria-label": t("common.action.close"),
- size: 44,
- }}
>
{supportsDetails && (
@@ -113,8 +103,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
size="lg"
leftSection={ }
onClick={() => {
- openDetailsAfterActionsRef.current = true;
actionsDisclosure.close();
+ detailsDisclosure.open();
}}
>
{t("board.mobile.details")}
@@ -140,74 +130,37 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
);
})}
- {queryKey && }
+ : }
+ onClick={refresh}
+ >
+ {t("item.menu.label.refresh")}
+
actionTriggerRef.current?.focus()}
- closeButtonProps={{
- "aria-label": t("common.action.close"),
- size: 44,
- }}
styles={{
- content: {
- display: "flex",
- flexDirection: "column",
- height: "100dvh",
- paddingTop: "env(safe-area-inset-top)",
- paddingLeft: "env(safe-area-inset-left)",
- paddingRight: "env(safe-area-inset-right)",
- },
body: {
- flex: "1 1 auto",
- minHeight: 0,
+ height: "calc(100dvh - 4rem)",
overflowY: "auto",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
},
}}
>
-
+
>
);
};
-
-const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => {
- const t = useI18n();
- const queryClient = useQueryClient();
- const isFetching = useIsFetching({ queryKey }) > 0;
-
- return (
- : }
- 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 a17197ba1..95ebcf9f0 100644
--- a/apps/nextjs/src/components/layout/header/search.tsx
+++ b/apps/nextjs/src/components/layout/header/search.tsx
@@ -6,15 +6,11 @@ import { IconSearch } from "@tabler/icons-react";
import { openSpotlight } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
-import { useIsMobileBoard } from "../../board/use-mobile-board";
import { HeaderButton } from "./button";
import classes from "./search.module.css";
export const DesktopSearchInput = () => {
const t = useI18n();
- const isMobileBoard = useIsMobileBoard();
-
- if (isMobileBoard) return null;
return (
{
export const MobileSearchButton = () => {
const t = useI18n();
- const isMobileBoard = useIsMobileBoard();
return (
-
+
);
diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts
deleted file mode 100644
index ffe381f61..000000000
--- a/e2e/mobile-board.spec.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-import { chromium, devices, expect } from "@playwright/test";
-import { createId } from "@paralleldrive/cuid2";
-import SuperJSON from "superjson";
-import { describe, test } from "vitest";
-
-import { eq } from "@homarr/db";
-import * as sqliteSchema from "@homarr/db/schema/sqlite";
-
-import { createHomarrContainer } from "./shared/create-homarr-container";
-import { createSqliteDbFileAsync } from "./shared/e2e-db";
-import type { SqliteDatabase } from "./shared/e2e-db";
-import { seedAdminUserAsync } from "./shared/seed-admin-user";
-
-const adminCredentials = {
- username: "admin",
- password: "Comp(exP4sswOrd",
-};
-
-const boardName = "mobile-e2e";
-
-const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => {
- const boardId = createId();
- const layoutId = createId();
- const firstSectionId = createId();
- const secondSectionId = createId();
-
- await db.insert(sqliteSchema.boards).values({
- id: boardId,
- name: boardName,
- creatorId: userId,
- });
- await db.insert(sqliteSchema.layouts).values({
- id: layoutId,
- name: "Desktop",
- boardId,
- columnCount: 12,
- breakpoint: 0,
- });
- await db.insert(sqliteSchema.sections).values([
- {
- id: firstSectionId,
- boardId,
- kind: "category",
- name: "First section",
- xOffset: 0,
- yOffset: 0,
- },
- {
- id: secondSectionId,
- boardId,
- kind: "category",
- name: "Second section",
- xOffset: 0,
- yOffset: 20,
- },
- ]);
-
- const itemRows = Array.from({ length: 36 }, (_, index) => {
- const id = createId();
- return {
- id,
- boardId,
- kind: index === 0 ? ("notebook" as const) : ("clock" as const),
- options: SuperJSON.stringify(index === 0 ? { content: "Mobile notes
" } : {}),
- advancedOptions: SuperJSON.stringify({}),
- };
- });
- await db.insert(sqliteSchema.items).values(itemRows);
- await db.insert(sqliteSchema.itemLayouts).values(
- itemRows.map((item, index) => ({
- itemId: item.id,
- layoutId,
- sectionId: index < 18 ? firstSectionId : secondSectionId,
- xOffset: (index % 6) * 2,
- yOffset: Math.floor((index % 18) / 6) * 2,
- width: 2,
- height: 2,
- })),
- );
- await db
- .update(sqliteSchema.users)
- .set({
- homeBoardId: boardId,
- mobileHomeBoardId: boardId,
- completedBoardTour: true,
- })
- .where(eq(sqliteSchema.users.id, userId));
-};
-
-describe("Automatic mobile board", () => {
- test("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => {
- const { db, localMountPath } = await createSqliteDbFileAsync();
- const { userId } = await seedAdminUserAsync(db, adminCredentials);
- await seedMobileBoardAsync(db, userId);
-
- const homarrContainer = await createHomarrContainer({
- environment: {
- AUTH_PROVIDERS: "credentials",
- },
- mounts: {
- "/appdata": localMountPath,
- },
- }).start();
- const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
- const browser = await chromium.launch();
- const loginContext = await browser.newContext();
-
- try {
- const loginPage = await loginContext.newPage();
- await loginPage.goto(`${baseUrl}/auth/login`);
- await loginPage.getByLabel("Username").fill(adminCredentials.username);
- await loginPage.locator("#password").fill(adminCredentials.password);
- await loginPage.locator("button[type='submit']").click();
- await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 });
- const storageState = await loginContext.storageState();
-
- const mobileViewports = [
- { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true },
- { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true },
- { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true },
- {
- name: "phone landscape",
- width: 844,
- height: 390,
- userAgent: devices["iPhone 13"].userAgent,
- phone: true,
- },
- {
- name: "narrow tablet",
- width: 768,
- height: 1024,
- userAgent: devices["iPad (gen 7)"].userAgent,
- phone: false,
- },
- ];
-
- for (const viewport of mobileViewports) {
- const context = await browser.newContext({
- storageState,
- viewport: { width: viewport.width, height: viewport.height },
- userAgent: viewport.userAgent,
- hasTouch: true,
- isMobile: viewport.phone,
- reducedMotion: viewport.width === 390 ? "reduce" : "no-preference",
- });
- const page = await context.newPage();
- await page.goto(`${baseUrl}/boards/${boardName}`);
-
- await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 });
- await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36);
- await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0);
- await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible();
-
- const layoutMetrics = await page.evaluate(() => {
- const grids = Array.from(document.querySelectorAll("[data-mobile-board] > div"));
- const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid");
- const items = Array.from(document.querySelectorAll("[data-mobile-board-item]"));
- const focusable = Array.from(
- document.querySelectorAll(
- '[data-mobile-board] a[href], [data-mobile-board] button, [data-mobile-board] input, [data-mobile-board] select, [data-mobile-board] textarea, [data-mobile-board] [tabindex]:not([tabindex="-1"])',
- ),
- );
-
- return {
- columns: twoColumnGrids.map(
- (grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length,
- ),
- documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
- overflowingItems: items.filter((item) => item.scrollWidth > item.clientWidth + 1).length,
- outOfBoundsControls: focusable.filter((element) => {
- const rect = element.getBoundingClientRect();
- return rect.left < -1 || rect.right > window.innerWidth + 1;
- }).length,
- layoutControls: document.querySelectorAll(
- '[data-testid*="layout"], button[aria-label*="resize" i], button[aria-label*="move" i]',
- ).length,
- };
- });
-
- expect(
- layoutMetrics.columns.length > 0 && layoutMetrics.columns.every((count) => count === 2),
- viewport.name,
- ).toBe(true);
- expect(layoutMetrics.documentOverflow, viewport.name).toBeLessThanOrEqual(0);
- expect(layoutMetrics.overflowingItems, viewport.name).toBe(0);
- expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0);
- expect(layoutMetrics.layoutControls, viewport.name).toBe(0);
-
- if (viewport.width === 320) {
- await page.evaluate(() => {
- document.documentElement.style.fontSize = "200%";
- });
- const zoomOverflow = await page.evaluate(() => ({
- document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
- items: Array.from(document.querySelectorAll("[data-mobile-board-item]")).filter(
- (item) => item.scrollWidth > item.clientWidth + 1,
- ).length,
- }));
- expect(zoomOverflow.document).toBeLessThanOrEqual(0);
- expect(zoomOverflow.items).toBe(0);
- }
-
- if (viewport.width === 390) {
- await page.getByRole("button", { name: "More" }).click();
- const moreDrawer = page.getByRole("dialog", { name: "More" });
- await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible();
- await moreDrawer.getByRole("button", { name: "Second section" }).click();
- await expect(page.getByRole("heading", { name: "Second section" })).toBeFocused();
-
- const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i });
- await notebookActions.click();
- await page.getByRole("button", { name: "Open widget details" }).click();
- const details = page.getByRole("dialog", { name: "Notebook" });
- await expect(details).toBeVisible();
- await details.getByRole("button", { name: "Close" }).click();
- await expect(notebookActions).toBeFocused();
-
- const rtlOverflow = await page.evaluate(() => {
- document.documentElement.dir = "rtl";
- return document.documentElement.scrollWidth - document.documentElement.clientWidth;
- });
- expect(rtlOverflow).toBeLessThanOrEqual(0);
- }
-
- await context.close();
- }
-
- const desktopContext = await browser.newContext({
- storageState,
- viewport: { width: 800, height: 900 },
- });
- const desktopPage = await desktopContext.newPage();
- await desktopPage.goto(`${baseUrl}/boards/${boardName}`);
- await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0);
- await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36);
- await desktopContext.close();
- } finally {
- await loginContext.close();
- await browser.close();
- await homarrContainer.stop();
- }
- }, 180_000);
-});
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 1993b8231..a51313972 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -1592,9 +1592,6 @@ const getHomeIdBoardAsync = async (
user: InferSelectModel | null,
deviceType: DeviceType,
) => {
- // Mobile home-board selection remains device-based because the server does
- // not know the resolved CSS viewport. Tablet rendering is resolved against
- // 48em on the client and must not be guessed here.
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
if (!user) {
@@ -1616,7 +1613,8 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
- or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
+ not(isNull(groups[settingKey])),
+ not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 94a46bc31..479448584 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -516,64 +516,6 @@ describe("getHomeBoard should return home board", () => {
});
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
- test.each([
- { deviceType: "mobile", expectedBoard: "mobile-home" },
- { deviceType: "tablet", expectedBoard: "desktop-home" },
- ] as const)(
- "should use the $expectedBoard preference for a $deviceType device",
- async ({ deviceType, expectedBoard }) => {
- // Arrange
- const db = createDb();
- const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
-
- const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
- const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
- await db
- .update(users)
- .set({
- homeBoardId: desktopBoardProps.boardId,
- mobileHomeBoardId: mobileBoardProps.boardId,
- })
- .where(eq(users.id, defaultCreatorId));
-
- // Act
- const result = await caller.getHomeBoard();
-
- // Assert
- expectInputToBeFullBoardWithName(result, {
- ...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
- name: expectedBoard,
- });
- },
- );
- test("should return a mobile-only group home board", async () => {
- // Arrange
- const db = createDb();
- const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
-
- const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
- const groupId = createId();
- await db.insert(groups).values({
- id: groupId,
- name: "mobile group",
- position: 1,
- homeBoardId: null,
- mobileHomeBoardId: mobileBoardProps.boardId,
- });
- await db.insert(groupMembers).values({
- userId: defaultCreatorId,
- groupId,
- });
-
- // Act
- const result = await caller.getHomeBoard();
-
- // Assert
- expectInputToBeFullBoardWithName(result, {
- name: "group-mobile-home",
- ...mobileBoardProps,
- });
- });
test("should return global home board when user doesn't have one", async () => {
// Arrange
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
@@ -1485,12 +1427,9 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
- await db
- .insert(users)
- .values({
- id: defaultCreatorId,
- })
- .onConflictDoNothing();
+ await db.insert(users).values({
+ id: defaultCreatorId,
+ });
const boardId = createId();
await db.insert(boards).values({
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
index 19437e139..729e82634 100644
--- a/packages/api/src/router/widgets/calendar.ts
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -1,13 +1,16 @@
import { z } from "zod/v4";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
-import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+const logger = createLogger({ module: "calendarRouter" });
+
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
@@ -27,9 +30,8 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
+ const settled = await Promise.allSettled(
+ ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
@@ -42,8 +44,25 @@ export const calendarRouter = createTRPCRouter({
kind: integration.kind,
},
};
- },
- { throwOnAllFailure: true },
+ }),
);
+
+ return settled.flatMap((result, index) => {
+ if (result.status === "fulfilled") {
+ return [result.value];
+ }
+ const integration = ctx.integrations[index];
+ logger.warn(
+ new ErrorWithMetadata(
+ "Calendar integration request failed; skipping events for this integration",
+ {
+ integrationId: integration?.id,
+ integrationKind: integration?.kind,
+ },
+ { cause: result.reason },
+ ),
+ );
+ return [];
+ });
}),
});
diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts
index 0bcd1fc55..bdce2473f 100644
--- a/packages/api/src/router/widgets/downloads.ts
+++ b/packages/api/src/router/widgets/downloads.ts
@@ -24,18 +24,14 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
- const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
- data,
- };
- },
- { throwOnAllFailure: true },
- );
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
+ const { data, timestamp } = await innerHandler.getDataAsync();
+ return {
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
+ data,
+ };
+ });
}),
pause: protectedProcedure
.meta({
diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts
index 28be00d4a..1957f459c 100644
--- a/packages/api/src/router/widgets/health-monitoring.ts
+++ b/packages/api/src/router/widgets/health-monitoring.ts
@@ -27,19 +27,15 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- healthInfo: data,
- updatedAt: timestamp,
- };
- },
- { throwOnAllFailure: true },
- );
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ healthInfo: data,
+ updatedAt: timestamp,
+ };
+ });
}),
listStorageVolumes: publicProcedure
.meta({
diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts
index 179610e31..f05a46142 100644
--- a/packages/api/src/router/widgets/media-release.ts
+++ b/packages/api/src/router/widgets/media-release.ts
@@ -9,24 +9,20 @@ export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
- const results = await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getDataAsync();
+ const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- releases: data,
- };
- },
- { throwOnAllFailure: true },
- );
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ releases: data,
+ };
+ });
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts
index 700b1d007..fbd192499 100644
--- a/packages/api/src/router/widgets/media-requests.ts
+++ b/packages/api/src/router/widgets/media-requests.ts
@@ -25,14 +25,10 @@ export const mediaRequestsRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
- const results = await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
- return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
- },
- { throwOnAllFailure: true },
- );
+ const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
+ return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
+ });
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
@@ -61,14 +57,10 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
- const results = await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
- return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
- },
- { throwOnAllFailure: true },
- );
+ const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
+ return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
+ });
return {
stats: results.flatMap((result) => result.data.stats),
users: results
diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts
index 03324ab62..3e3e64a4f 100644
--- a/packages/api/src/router/widgets/media-server.ts
+++ b/packages/api/src/router/widgets/media-server.ts
@@ -23,15 +23,11 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const { data } = await mediaServerRequestHandler
- .handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
- .getDataAsync();
- return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
- },
- { throwOnAllFailure: true },
- );
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { data } = await mediaServerRequestHandler
+ .handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
+ .getDataAsync();
+ return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
+ });
}),
});
diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts
index 2788cb503..bae8ba9fd 100644
--- a/packages/api/src/router/widgets/notifications.ts
+++ b/packages/api/src/router/widgets/notifications.ts
@@ -13,23 +13,19 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const innerHandler = notificationsRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getDataAsync();
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = notificationsRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- data,
- };
- },
- { throwOnAllFailure: true },
- );
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ data,
+ };
+ });
}),
});
diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts
index 2ec1f1045..6df14090c 100644
--- a/packages/api/src/router/widgets/uptime-kuma.ts
+++ b/packages/api/src/router/widgets/uptime-kuma.ts
@@ -8,21 +8,17 @@ export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
- return await settleIntegrationQueries(
- ctx.integrations,
- async (integration) => {
- const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getDataAsync();
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- integrationName: integration.name,
- integrationUrl: integration.url,
- dashboard: data,
- updatedAt: timestamp,
- };
- },
- { throwOnAllFailure: true },
- );
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: data,
+ updatedAt: timestamp,
+ };
+ });
}),
});
diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts
deleted file mode 100644
index fe3b460d5..000000000
--- a/packages/api/src/settle-integrations.spec.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-// @vitest-environment node
-
-import { describe, expect, it, vi } from "vitest";
-
-import { settleIntegrationQueries } from "./settle-integrations";
-
-const integrations = [
- { id: "first", name: "First", kind: "mock" },
- { id: "second", name: "Second", kind: "mock" },
-];
-
-describe("settleIntegrationQueries", () => {
- it("retains successful integration results when another integration fails", async () => {
- const results = await settleIntegrationQueries(
- integrations,
- vi.fn(async (integration) => {
- if (integration.id === "first") throw new Error("offline");
- return integration.name;
- }),
- { throwOnAllFailure: true },
- );
-
- expect(results).toEqual(["Second"]);
- });
-
- it("throws when every integration fails and throwOnAllFailure is enabled", async () => {
- await expect(
- settleIntegrationQueries(
- integrations,
- vi.fn(async () => {
- throw new Error("offline");
- }),
- { throwOnAllFailure: true },
- ),
- ).rejects.toThrow("offline");
- });
-
- it("does not treat an empty integration list as a failure", async () => {
- await expect(
- settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }),
- ).resolves.toEqual([]);
- });
-});
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 5a66c98a6..0bf1a888f 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1853,8 +1853,6 @@
"internalServerError": "Failed to control DNS Hole"
},
"controls": {
- "enable": "Enable",
- "disable": "Disable",
"enableAll": "Enable All",
"disableAll": "Disable All",
"setTimer": "Set Timer",
@@ -3888,8 +3886,7 @@
}
},
"status": {
- "unhealthy": "Unhealthy",
- "free": "{value}% free"
+ "unhealthy": "Unhealthy"
}
},
"customApi": {
@@ -4167,18 +4164,15 @@
},
"board": {
"mobile": {
- "actions": "Actions for {title}",
+ "actions": "Widget actions",
"details": "Open widget details",
"empty": {
"title": "This board is empty",
- "editorDescription": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically.",
- "viewerDescription": "No items have been added to this board yet."
+ "description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically."
},
"more": "More",
"profile": "Profile and account",
"sections": "Jump to section",
- "dataWarning": "Data may be incomplete or outdated",
- "systemStatus": "{up} up · {attention} need attention",
"currentBoard": "Current board"
},
"action": {
diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts
index 7c72f5e5d..a54aada3f 100644
--- a/packages/widgets/src/anchor-note/index.ts
+++ b/packages/widgets/src/anchor-note/index.ts
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "anchorNotes"]],
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx
index 1d9f40dde..56e079a8c 100644
--- a/packages/widgets/src/app/component.tsx
+++ b/packages/widgets/src/app/component.tsx
@@ -14,21 +14,16 @@ import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
-import { WidgetMobileLoading } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
-export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
+export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
- const {
- data: app,
- error,
- isPending,
- } = clientApi.app.byId.useQuery({
+ const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
useRegisterSpotlightContextResults(
@@ -53,14 +48,10 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
[app, options.openInNewTab],
);
- if (displayMode === "mobileSummary" && isPending) return ;
- if (displayMode === "mobileSummary" && error && !app) throw error;
if (!app) return ;
- const isMobileSummary = displayMode === "mobileSummary";
- const isTiny = !isMobileSummary && (height < 100 || width < 100);
- const layout = isMobileSummary ? "column" : options.layout;
- const isColumnLayout = layout.startsWith("column");
+ const isTiny = height < 100 || width < 100;
+ const isColumnLayout = options.layout.startsWith("column");
return (
)}
- {!isMobileSummary && options.descriptionDisplayMode === "normal" && (
+ {options.descriptionDisplayMode === "normal" && (
({
showBroadcastMessage: factory.switch({
diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts
index 70214147f..25af0bdaf 100644
--- a/packages/widgets/src/audio-stats/index.ts
+++ b/packages/widgets/src/audio-stats/index.ts
@@ -14,8 +14,6 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "audioStats"]],
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts
index 10b6e46bc..f2e79f570 100644
--- a/packages/widgets/src/bazarr/index.ts
+++ b/packages/widgets/src/bazarr/index.ts
@@ -5,7 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
- queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx
index 708094f8c..3f934423d 100644
--- a/packages/widgets/src/beszel-alerts/component.tsx
+++ b/packages/widgets/src/beszel-alerts/component.tsx
@@ -13,7 +13,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
const alertIconMap: Record = {
CPU: Cpu,
@@ -36,7 +35,6 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
- displayMode,
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -70,16 +68,9 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
- const failedResults = results.filter((result) => "error" in result);
-
if (alertsError) throw alertsError;
- if (results.length > 0 && failedResults.length === results.length) {
- throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
- }
if (isPending) {
- if (displayMode === "mobileSummary") return ;
-
return (
@@ -87,17 +78,6 @@ export default function BeszelAlertsWidget({
);
}
- if (displayMode === "mobileSummary") {
- return (
- 0}
- />
- );
- }
-
return (
diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts
index 715d74e6e..5e3799a90 100644
--- a/packages/widgets/src/beszel-alerts/index.ts
+++ b/packages/widgets/src/beszel-alerts/index.ts
@@ -6,14 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
- queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx
index 3b6cc612a..dd2f56c54 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -39,7 +39,6 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
-import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary";
interface SizeConfig {
iconSize: number;
@@ -367,23 +366,7 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
);
};
-export default function BeszelSystemGridWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemGrid">) {
- const t = useScopedI18n("widget.beszelSystemGrid");
-
- if (displayMode === "mobileSummary") {
- return (
-
- );
- }
-
- return ;
-}
-
-function BeszelSystemGridContent({
+export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts
index 7fdfb2237..69dc7e2ba 100644
--- a/packages/widgets/src/beszel-system-grid/index.ts
+++ b/packages/widgets/src/beszel-system-grid/index.ts
@@ -6,13 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx
index f774f70cd..91dcd7292 100644
--- a/packages/widgets/src/beszel-system-stats/component.tsx
+++ b/packages/widgets/src/beszel-system-stats/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useMemo, useState } from "react";
+import { useCallback, useMemo } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
@@ -15,20 +15,9 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
-import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
-export default function BeszelSystemStatsWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemStats">) {
- const t = useScopedI18n("widget.beszelSystemStats");
-
- if (displayMode === "mobileSummary") {
- return ;
- }
-
- return ;
-}
-
-function BeszelSystemStatsContent({
+export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
@@ -36,7 +25,6 @@ function BeszelSystemStatsContent({
boardId,
itemId,
setOptions,
- isReadOnly = false,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -53,29 +41,18 @@ function BeszelSystemStatsContent({
() => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
[systemsResult],
);
- const [temporarySystemId, setTemporarySystemId] = useState(null);
- const [temporaryTimePeriod, setTemporaryTimePeriod] = useState(null);
- const configuredSystem = temporarySystemId ?? options.systemId;
- const selectedSystem =
- isReadOnly && !systems.some((system) => system.value === configuredSystem)
- ? (systems[0]?.value ?? "")
- : configuredSystem || systems[0]?.value || "";
+ const selectedSystem = options.systemId || systems[0]?.value || "";
const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
const systemExists = systems.some((system) => system.value === selectedSystem);
const handleSelectSystem = useCallback(
(value: string) => {
- if (isReadOnly) {
- setTemporarySystemId(value);
- return;
- }
-
setOptions({ newOptions: { systemId: value } });
if (hasChangeAccess && boardId && itemId) {
saveItemOptions({ boardId, itemId, newOptions: { systemId: value } });
}
},
- [setOptions, isReadOnly, hasChangeAccess, boardId, itemId, saveItemOptions],
+ [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
if (systemsError) throw systemsError;
@@ -163,7 +140,7 @@ function BeszelSystemStatsContent({
600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
@@ -175,13 +152,7 @@ function BeszelSystemStatsContent({
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
- onSwitchToHistorical={() => {
- if (isReadOnly) {
- setTemporaryTimePeriod("1h");
- return;
- }
- setOptions({ newOptions: { timePeriod: "1h" } });
- }}
+ onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts
index 40baa556d..af2907b25 100644
--- a/packages/widgets/src/beszel-system-stats/index.ts
+++ b/packages/widgets/src/beszel-system-stats/index.ts
@@ -16,13 +16,6 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx
index 305bdffee..5352eb170 100644
--- a/packages/widgets/src/beszel-system-table/component.tsx
+++ b/packages/widgets/src/beszel-system-table/component.tsx
@@ -38,7 +38,6 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
-import { BeszelSystemsMobileSummary } from "../beszel/_shared/mobile-summary";
const directionMultiplier: Record = { asc: 1, desc: -1 };
@@ -71,23 +70,7 @@ const getSizeConfig = (width: number): SizeConfig => {
};
};
-export default function BeszelSystemTableWidget({ displayMode, ...props }: WidgetComponentProps<"beszelSystemTable">) {
- const t = useScopedI18n("widget.beszelSystemTable");
-
- if (displayMode === "mobileSummary") {
- return (
-
- );
- }
-
- return ;
-}
-
-function BeszelSystemTableContent({
+export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts
index 17bfdc1e0..ba90cb5e9 100644
--- a/packages/widgets/src/beszel-system-table/index.ts
+++ b/packages/widgets/src/beszel-system-table/index.ts
@@ -25,13 +25,6 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
diff --git a/packages/widgets/src/beszel/_shared/mobile-summary.tsx b/packages/widgets/src/beszel/_shared/mobile-summary.tsx
deleted file mode 100644
index 7f1caa6a0..000000000
--- a/packages/widgets/src/beszel/_shared/mobile-summary.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import { clientApi } from "@homarr/api/client";
-import { useI18n } from "@homarr/translation/client";
-
-import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
-import { useBeszelFilteredSystems } from "./hooks";
-
-interface BeszelSystemsMobileSummaryProps {
- integrationIds: string[];
- label: string;
- statusFilter: string;
-}
-
-export const BeszelSystemsMobileSummary = ({
- integrationIds,
- label,
- statusFilter,
-}: BeszelSystemsMobileSummaryProps) => {
- const t = useI18n();
- const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
- const systems = useBeszelFilteredSystems(results, statusFilter);
- const failedResults = results.filter((result) => "error" in result);
-
- if (isPending) return ;
- if (error) throw error;
- if (results.length > 0 && failedResults.length === results.length) {
- throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel"));
- }
-
- const up = systems.filter((system) => system.status === "up").length;
-
- return (
- 0}
- />
- );
-};
diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx
index 685983859..c57b909fd 100644
--- a/packages/widgets/src/bookmarks/index.tsx
+++ b/packages/widgets/src/bookmarks/index.tsx
@@ -10,8 +10,6 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
- mobile: { supportsDetailView: true },
- queryKey: [["app"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx
index ccb07e888..84b8650a0 100644
--- a/packages/widgets/src/calendar/component.tsx
+++ b/packages/widgets/src/calendar/component.tsx
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
-import { Box, useMantineTheme } from "@mantine/core";
+import { Center, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
@@ -13,7 +13,6 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
@@ -41,11 +40,9 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
- const { data, error, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input);
- const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
+ const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
- if (displayMode === "mobileSummary" && isPending) return ;
- if (displayMode === "mobileSummary" && error && !data) throw error;
+ const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
return (
);
};
@@ -67,18 +63,9 @@ interface CalendarBaseProps {
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
displayMode?: WidgetComponentProps<"calendar">["displayMode"];
- isStale?: boolean;
}
-const CalendarBase = ({
- isEditMode,
- events,
- month,
- setMonth,
- options,
- displayMode,
- isStale = false,
-}: CalendarBaseProps) => {
+const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -96,32 +83,30 @@ const CalendarBase = ({
.filter((event) => event.startDate.getTime() >= Date.now())
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime())[0];
const link = nextEvent?.links[0];
- const summary = (
-
- );
-
- if (!link) return summary;
return (
-
- {summary}
-
+
+
+
+ {nextEvent?.title ?? normalizedEvents.length}
+
+
+ {nextEvent
+ ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate)
+ : t("name")}
+
+
+
);
}
diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts
index 36365abd6..4bd45f57d 100644
--- a/packages/widgets/src/calendar/index.ts
+++ b/packages/widgets/src/calendar/index.ts
@@ -15,7 +15,6 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar"
supportsCompactSummary: true,
supportsDetailView: true,
},
- queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
diff --git a/packages/widgets/src/common/mobile-summary.tsx b/packages/widgets/src/common/mobile-summary.tsx
index 4a8af6270..d3f6e9bf1 100644
--- a/packages/widgets/src/common/mobile-summary.tsx
+++ b/packages/widgets/src/common/mobile-summary.tsx
@@ -1,57 +1,29 @@
-"use client";
-
-import { Center, Group, Loader, Stack, Text } from "@mantine/core";
-import { IconAlertTriangle } from "@tabler/icons-react";
-
-import { useI18n } from "@homarr/translation/client";
+import { Center, Group, Stack, Text } from "@mantine/core";
interface WidgetMobileSummaryProps {
value: number | string;
label: string;
description?: string;
- isStale?: boolean;
}
-export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) {
- const t = useI18n();
-
+export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) {
return (
-
- {value}
-
-
- {label}
-
+
+
+ {value}
+
+
+ {label}
+
+
{description !== undefined && (
-
+
{description}
)}
- {isStale && (
-
-
-
- {t("board.mobile.dataWarning")}
-
-
- )}
);
}
-
-export function WidgetMobileLoading() {
- const t = useI18n();
-
- return (
-
-
-
- );
-}
diff --git a/packages/widgets/src/coolify/applications-section.tsx b/packages/widgets/src/coolify/applications-section.tsx
index e0ae5691f..bbc52c662 100644
--- a/packages/widgets/src/coolify/applications-section.tsx
+++ b/packages/widgets/src/coolify/applications-section.tsx
@@ -13,10 +13,9 @@ interface ApplicationsSectionProps {
applications: CoolifyApplicationWithContext[];
baseUrl: string;
isTiny: boolean;
- isMobileDetail: boolean;
}
-export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) {
+export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length;
@@ -35,14 +34,7 @@ export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDet
{applications.length > 0 ? (
{applications.map((app) => (
-
+
))}
) : (
diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx
index c37f66c05..46940f56e 100644
--- a/packages/widgets/src/coolify/component.tsx
+++ b/packages/widgets/src/coolify/component.tsx
@@ -10,12 +10,7 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
-export default function CoolifyWidget({
- options,
- integrationIds,
- width,
- displayMode,
-}: WidgetComponentProps<"coolify">) {
+export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -26,24 +21,16 @@ export default function CoolifyWidget({
);
}
- return (
-
- );
+ return ;
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
- isMobileDetail: boolean;
}
-function CoolifyContent({ integrationIds, options, width, isMobileDetail }: CoolifyContentProps) {
+function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
const isTiny = width < 256;
@@ -51,15 +38,7 @@ function CoolifyContent({ integrationIds, options, width, isMobileDetail }: Cool
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
- return (
-
- );
+ return ;
}
return (
@@ -72,7 +51,6 @@ function CoolifyContent({ integrationIds, options, width, isMobileDetail }: Cool
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
- isMobileDetail={isMobileDetail}
/>
))}
diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts
index b0e478985..cebf9de05 100644
--- a/packages/widgets/src/coolify/index.ts
+++ b/packages/widgets/src/coolify/index.ts
@@ -5,8 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx
index dca80819c..0f4522dc4 100644
--- a/packages/widgets/src/coolify/instance-card.tsx
+++ b/packages/widgets/src/coolify/instance-card.tsx
@@ -18,10 +18,9 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
- isMobileDetail: boolean;
}
-export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDetail }: InstanceCardProps) {
+export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -100,24 +99,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isMobileDet
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
- isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
-
+
)}
{options.showServices && (
-
+
)}
diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx
index 787557413..9f6cfe1ae 100644
--- a/packages/widgets/src/coolify/resource-row.tsx
+++ b/packages/widgets/src/coolify/resource-row.tsx
@@ -3,8 +3,6 @@
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
-import { useScopedI18n } from "@homarr/translation/client";
-
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -23,18 +21,11 @@ interface ResourceRowProps {
baseUrl: string;
isTiny: boolean;
resourceType: "application" | "service";
- isMobileDetail: boolean;
}
-export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetail }: ResourceRowProps) {
- const tCommon = useScopedI18n("common");
- const tApp = useScopedI18n("widget.app");
+export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) {
const status = parseStatus(item.status ?? "");
const statusColor = getStatusColor(status);
- const fqdn = cleanFqdn(item.fqdn);
- const actionIconSize = isMobileDetail ? 44 : "xs";
- const resourceLabel = resourceType === "application" ? tCommon("applications") : tCommon("services");
- const openInNewTabLabel = tApp("option.openInNewTab.label");
const resourceUrl =
item.projectUuid && item.environmentUuid
@@ -58,42 +49,18 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType, isMobileDetai
)}
- {fqdn && (
-
+ {cleanFqdn(item.fqdn) && (
+
)}
{resourceUrl && (
-
+
)}
{logsUrl && (
-
+
)}
diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx
index b8935ae72..543df1c67 100644
--- a/packages/widgets/src/coolify/servers-section.tsx
+++ b/packages/widgets/src/coolify/servers-section.tsx
@@ -15,7 +15,6 @@ interface ServersSectionProps {
isTiny: boolean;
showIp: boolean;
onToggleIp: () => void;
- isMobileDetail: boolean;
}
export function ServersSection({
@@ -25,7 +24,6 @@ export function ServersSection({
isTiny,
showIp,
onToggleIp,
- isMobileDetail,
}: ServersSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
@@ -42,11 +40,9 @@ export function ServersSection({
{
event.stopPropagation();
onToggleIp();
@@ -69,7 +65,6 @@ export function ServersSection({
baseUrl={baseUrl}
isTiny={isTiny}
showIp={showIp}
- isMobileDetail={isMobileDetail}
/>
))}
@@ -89,12 +84,10 @@ interface ServerRowProps {
baseUrl: string;
isTiny: boolean;
showIp: boolean;
- isMobileDetail: boolean;
}
-function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }: ServerRowProps) {
+function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) {
const t = useScopedI18n("widget.coolify");
- const tApp = useScopedI18n("widget.app");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
@@ -117,15 +110,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp, isMobileDetail }:
)}
-
+
diff --git a/packages/widgets/src/coolify/services-section.tsx b/packages/widgets/src/coolify/services-section.tsx
index 9a44c8f38..c225db27d 100644
--- a/packages/widgets/src/coolify/services-section.tsx
+++ b/packages/widgets/src/coolify/services-section.tsx
@@ -13,10 +13,9 @@ interface ServicesSectionProps {
services: CoolifyServiceWithContext[];
baseUrl: string;
isTiny: boolean;
- isMobileDetail: boolean;
}
-export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: ServicesSectionProps) {
+export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length;
@@ -35,14 +34,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isMobileDetail }: S
{services.length > 0 ? (
{services.map((service) => (
-
+
))}
) : (
diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx
index 4c0dfd33e..ae44b998f 100644
--- a/packages/widgets/src/coolify/single-instance-layout.tsx
+++ b/packages/widgets/src/coolify/single-instance-layout.tsx
@@ -18,16 +18,9 @@ interface SingleInstanceLayoutProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
- isMobileDetail: boolean;
}
-export function SingleInstanceLayout({
- instance,
- options,
- isTiny,
- widgetKey,
- isMobileDetail,
-}: SingleInstanceLayoutProps) {
+export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -72,24 +65,13 @@ export function SingleInstanceLayout({
isTiny={isTiny}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
- isMobileDetail={isMobileDetail}
/>
)}
{options.showApplications && (
-
+
)}
{options.showServices && (
-
+
)}
diff --git a/packages/widgets/src/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx
index 03ff64fdc..ea89301d8 100644
--- a/packages/widgets/src/custom-api/component.tsx
+++ b/packages/widgets/src/custom-api/component.tsx
@@ -309,21 +309,14 @@ function openJsonInBrowser(json: unknown) {
}
function RawDisplay({ data }: { data: Record }) {
- const tApp = useScopedI18n("widget.app");
const maxHeight = (data.maxHeight as number) ?? 300;
const jsonString = JSON.stringify(data.data, null, 2);
- const openJsonLabel = `${tApp("option.openInNewTab.label")}: JSON`;
return (
-
- openJsonInBrowser(data.data)}
- >
+
+ openJsonInBrowser(data.data)}>
diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts
index 90ee7bef7..2bb9d3867 100644
--- a/packages/widgets/src/custom-api/index.ts
+++ b/packages/widgets/src/custom-api/index.ts
@@ -6,8 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "customApi"]],
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
diff --git a/packages/widgets/src/custom-api/jsx-interactive-components.tsx b/packages/widgets/src/custom-api/jsx-interactive-components.tsx
index 1fe2d2cff..2b18342a3 100644
--- a/packages/widgets/src/custom-api/jsx-interactive-components.tsx
+++ b/packages/widgets/src/custom-api/jsx-interactive-components.tsx
@@ -2,19 +2,15 @@
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
-import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core";
+import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
-import { useScopedI18n } from "@homarr/translation/client";
-
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
- const tCommon = useScopedI18n("common");
- const { dir } = useDirection();
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
@@ -30,8 +26,6 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
- const PreviousIcon = dir === "rtl" ? IconChevronRight : IconChevronLeft;
- const NextIcon = dir === "rtl" ? IconChevronLeft : IconChevronRight;
return (
@@ -40,24 +34,22 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
setPage((p) => Math.max(0, p - 1))}
>
-
+
{clampedPage + 1} / {totalPages}
= totalPages - 1}
- aria-label={tCommon("action.next")}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
-
+
)}
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index bf6723c90..b5900267d 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -15,18 +15,16 @@ import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptions
export type WidgetDisplayMode = "default" | "mobileSummary" | "mobileDetail";
export interface WidgetMobilePresentation {
- width?: 1 | 2;
- height?: 1 | 2 | 3;
+ width: 1 | 2;
+ height: 1 | 2 | 3;
supportsCompactSummary?: boolean;
supportsDetailView?: boolean;
eager?: boolean;
- unmountWhenOffscreen?: boolean;
}
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
- mobileVisible?: boolean;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
@@ -113,7 +111,6 @@ export type WidgetComponentProps = WidgetProps
boardId: string | undefined; // undefined when in preview mode
displayMode?: WidgetDisplayMode;
isEditMode: boolean;
- isReadOnly?: boolean;
setOptions: ({ newOptions }: { newOptions: Partial>> }) => void;
width: number;
height: number;
diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx
index 3aa68a9b1..0a04779fa 100644
--- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx
+++ b/packages/widgets/src/dns-hole/controls/TimerModal.tsx
@@ -42,21 +42,12 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
setMinutes(0);
}}
title={t("widget.dnsHoleControls.controls.setTimer")}
- closeButtonProps={{
- "aria-label": t("common.action.close"),
- size: 44,
- }}
>
{t("widget.dnsHoleControls.controls.hours")}
- hoursHandlers.current?.decrement()}
- >
+ hoursHandlers.current?.decrement()}>
–
- hoursHandlers.current?.increment()}
- >
+ hoursHandlers.current?.increment()}>
+
{t("widget.dnsHoleControls.controls.minutes")}
- minutesHandlers.current?.decrement()}
- >
+ minutesHandlers.current?.decrement()}>
–
- minutesHandlers.current?.increment()}
- >
+ minutesHandlers.current?.increment()}>
+
@@ -117,7 +91,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
variant="light"
color="red"
leftSection={ }
- mih={44}
+ h="2rem"
w="12rem"
onClick={handleSetTimer}
>
diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx
index 4696dfca5..f46c3d746 100644
--- a/packages/widgets/src/dns-hole/controls/component.tsx
+++ b/packages/widgets/src/dns-hole/controls/component.tsx
@@ -301,7 +301,6 @@ const ControlsCard: React.FC = ({
size="sm"
color="green"
variant="light"
- aria-label={`${t("widget.dnsHoleControls.controls.enable")}: ${data.integration.name}`}
>
@@ -312,7 +311,6 @@ const ControlsCard: React.FC = ({
size="sm"
color="red"
variant="light"
- aria-label={`${t("widget.dnsHoleControls.controls.disable")}: ${data.integration.name}`}
>
@@ -325,7 +323,6 @@ const ControlsCard: React.FC = ({
size="sm"
color="yellow"
variant="light"
- aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
>
@@ -384,7 +381,6 @@ const ControlsCard: React.FC = ({
bd={0}
ms={"auto"}
variant="subtle"
- aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts
index 553116564..9340a642a 100644
--- a/packages/widgets/src/dns-hole/controls/index.ts
+++ b/packages/widgets/src/dns-hole/controls/index.ts
@@ -9,7 +9,6 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
- mobile: { supportsDetailView: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx
index 444cd93d5..e56cd5cf7 100644
--- a/packages/widgets/src/docker/component.tsx
+++ b/packages/widgets/src/docker/component.tsx
@@ -16,7 +16,6 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
@@ -36,7 +35,6 @@ const actionIconIconStyle: IconProps["style"] = {
const createColumns = (
t: ReturnType>,
- actionIconSize: "xs" | 44,
): MRT_ColumnDef[] => [
{
id: "name",
@@ -132,7 +130,6 @@ const createColumns = (
const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation({ onSettled });
const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation({ onSettled });
const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation({ onSettled });
- const toggleAction = row.original.state === "running" ? "stop" : "start";
const handleActionAsync = async (action: "start" | "stop" | "restart") => {
const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer;
@@ -158,13 +155,12 @@ const createColumns = (
return (
-
+
handleActionAsync(toggleAction)}
+ onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
@@ -174,13 +170,7 @@ const createColumns = (
- handleActionAsync("restart")}
- >
+ handleActionAsync("restart")}>
@@ -190,38 +180,12 @@ const createColumns = (
},
];
-export default function DockerWidget({ displayMode, ...props }: WidgetComponentProps<"dockerContainers">) {
- if (displayMode === "mobileSummary") return ;
- return ;
-}
-
-const DockerMobileSummary = () => {
- const t = useScopedI18n("docker");
- const { data, error, isPending } = clientApi.docker.getContainers.useQuery();
-
- if (isPending) return ;
- if (error && !data) throw error;
-
- const containers = data?.containers ?? [];
- const runningContainers = containers.filter((container) => container.state === "running").length;
-
- return (
-
- );
-};
-
-const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponentProps<"dockerContainers">) => {
+export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
const isTiny = width <= 256;
- const actionIconSize = displayMode === "mobileDetail" ? 44 : "xs";
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
- const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
+ const containers = data?.containers ?? [];
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -238,7 +202,7 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen
);
}, [containers]);
- const columns = useMemo(() => createColumns(t, actionIconSize), [t, actionIconSize]);
+ const columns = useMemo(() => createColumns(t), [t]);
const columnVisibility: MRT_VisibilityState = {
name: options.columns.includes("name"),
@@ -336,7 +300,7 @@ const DockerTable = ({ options, width, isEditMode, displayMode }: WidgetComponen
);
-};
+}
diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts
index 7aa41ecc4..4a988c779 100644
--- a/packages/widgets/src/docker/index.ts
+++ b/packages/widgets/src/docker/index.ts
@@ -25,13 +25,6 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
queryKey: [["docker", "getContainers"]],
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts
index 5daba3085..88a7e43fa 100644
--- a/packages/widgets/src/downloads/index.ts
+++ b/packages/widgets/src/downloads/index.ts
@@ -47,9 +47,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
- unmountWhenOffscreen: true,
},
- queryKey: [["widget", "downloads"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts
index da05f89df..e154fd3ac 100644
--- a/packages/widgets/src/firewall/index.ts
+++ b/packages/widgets/src/firewall/index.ts
@@ -7,8 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx
index 8b9a6a8f5..ead4fef92 100644
--- a/packages/widgets/src/health-monitoring/component.tsx
+++ b/packages/widgets/src/health-monitoring/component.tsx
@@ -8,7 +8,6 @@ import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { SystemHealthMonitoring } from "./system-health";
@@ -19,22 +18,11 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
- const { data: integrations = [], error, isPending } = clientApi.integration.byIds.useQuery(props.integrationIds);
+ const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
- if (props.displayMode === "mobileSummary" && isPending) return ;
- if (props.displayMode === "mobileSummary" && error && integrations.length === 0) throw error;
-
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
- if (props.displayMode === "mobileSummary") {
- return clusterIntegrationId ? (
-
- ) : (
-
- );
- }
-
if (!clusterIntegrationId) {
return ;
}
@@ -47,79 +35,24 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
return ;
}
- const tabs = (
-
-
-
- {t("widget.healthMonitoring.tab.system")}
-
-
- {t("widget.healthMonitoring.tab.cluster")}
-
-
-
-
-
-
-
-
-
+ return (
+
+
+
+
+ {t("widget.healthMonitoring.tab.system")}
+
+
+ {t("widget.healthMonitoring.tab.cluster")}
+
+
+
+
+
+
+
+
+
+
);
-
- if (props.displayMode === "mobileDetail") return tabs;
-
- return {tabs} ;
}
-
-const SystemHealthMobileSummary = ({ integrationIds, isStale }: { integrationIds: string[]; isStale: boolean }) => {
- const t = useI18n();
- const { data, error, isPending } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
- integrationIds,
- });
-
- if (isPending) return ;
- if (error && !data) throw error;
-
- const healthInfo = data?.[0]?.healthInfo;
- if (!healthInfo) return ;
-
- const totalMemory = healthInfo.memAvailableInBytes + healthInfo.memUsedInBytes;
- const memoryPercent = totalMemory > 0 ? Math.round((healthInfo.memUsedInBytes / totalMemory) * 100) : 0;
-
- return (
-
- );
-};
-
-const ClusterHealthMobileSummary = ({ integrationId, isStale }: { integrationId: string; isStale: boolean }) => {
- const t = useI18n();
- const { data, error, isPending } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
- integrationId,
- });
-
- if (isPending) return ;
- if (error && !data) throw error;
- if (!data) return ;
-
- const runningNodes = data.nodes.filter((node) => node.isRunning);
- const totalCpu = runningNodes.reduce((sum, node) => sum + node.cpu.cores, 0);
- const usedCpu = runningNodes.reduce((sum, node) => sum + node.cpu.utilization * node.cpu.cores, 0);
- const totalMemory = runningNodes.reduce((sum, node) => sum + node.memory.total, 0);
- const usedMemory = runningNodes.reduce((sum, node) => sum + node.memory.used, 0);
- const cpuPercent = totalCpu > 0 ? Math.round((usedCpu / totalCpu) * 100) : 0;
- const memoryPercent = totalMemory > 0 ? Math.round((usedMemory / totalMemory) * 100) : 0;
-
- return (
-
- );
-};
diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts
index 473425492..ff8eb87ab 100644
--- a/packages/widgets/src/health-monitoring/index.ts
+++ b/packages/widgets/src/health-monitoring/index.ts
@@ -8,14 +8,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
- queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx
index 27a9eab1e..3614a570c 100644
--- a/packages/widgets/src/health-monitoring/system-health.tsx
+++ b/packages/widgets/src/health-monitoring/system-health.tsx
@@ -83,12 +83,7 @@ export const SystemHealthMonitoring = ({
p="sm"
pos="relative"
>
-
+
-
+
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
index b11672aaa..ac794a631 100644
--- a/packages/widgets/src/iframe/component.tsx
+++ b/packages/widgets/src/iframe/component.tsx
@@ -6,11 +6,10 @@ import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
-import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
-export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
+export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
@@ -20,11 +19,6 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
if (!isSupportedProtocol(embedUrl)) {
return ;
}
- const frameLabel = getFrameLabel(embedUrl);
-
- if (displayMode === "mobileSummary") {
- return ;
- }
return (
@@ -32,7 +26,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
- title={`${t("widget.iframe.name")}: ${frameLabel}`}
+ title="widget iframe"
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -45,14 +39,6 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
const supportedProtocols = ["http", "https"];
-const getFrameLabel = (url: string) => {
- try {
- return new URL(url).hostname;
- } catch {
- return url;
- }
-};
-
const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts
index c193014d6..d4313aebe 100644
--- a/packages/widgets/src/iframe/index.ts
+++ b/packages/widgets/src/iframe/index.ts
@@ -5,12 +5,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
icon: IconBrowser,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- },
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts
index 54d429a7d..e515cd697 100644
--- a/packages/widgets/src/indexer-manager/index.ts
+++ b/packages/widgets/src/indexer-manager/index.ts
@@ -7,8 +7,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "indexerManager"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx
index 2bb3b482c..d2201072e 100644
--- a/packages/widgets/src/media-missing/component.tsx
+++ b/packages/widgets/src/media-missing/component.tsx
@@ -23,7 +23,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
+import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
@@ -37,13 +37,11 @@ export default function MediaMissingWidget({
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
- const { data, error, isPending } = clientApi.widget.mediaOrganizer.getData.useQuery(
+ const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
- if (displayMode === "mobileSummary" && isPending) return ;
- if (displayMode === "mobileSummary" && error && !data) throw error;
if (!data) return ;
if (data.length === 0) throw new NoIntegrationDataError();
if (!options.showMissing && !options.showQueued)
@@ -66,14 +64,7 @@ export default function MediaMissingWidget({
: { value: queuedCount, label: t("tab.queued") };
const description = options.showMissing && options.showQueued ? `${queuedCount} ${t("tab.queued")}` : undefined;
- return (
-
- );
+ return ;
}
const isThin = width > 0 && width < 160;
diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts
index 88a853e37..98209d43c 100644
--- a/packages/widgets/src/media-missing/index.ts
+++ b/packages/widgets/src/media-missing/index.ts
@@ -15,7 +15,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss
supportsCompactSummary: true,
supportsDetailView: true,
},
- queryKey: [["widget", "mediaOrganizer"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx
index 948f7f987..4f243c77a 100644
--- a/packages/widgets/src/media-releases/component.tsx
+++ b/packages/widgets/src/media-releases/component.tsx
@@ -15,36 +15,23 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
+import { WidgetMobileSummary } from "../common/mobile-summary";
export default function MediaReleasesWidget({
options,
integrationIds,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
- const { data, error, isPending } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
+ const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
const t = useI18n("widget.mediaReleases");
if (displayMode === "mobileSummary") {
- if (isPending) return ;
- if (error && !data) throw error;
-
- const releases = data ?? [];
- return (
-
- );
+ return ;
}
- const releases = data ?? [];
-
return (
{releases.map((item, index) => (
diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx
index 6ec502dec..865a35123 100644
--- a/packages/widgets/src/media-requests/list/component.tsx
+++ b/packages/widgets/src/media-requests/list/component.tsx
@@ -26,7 +26,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary";
+import { WidgetMobileSummary } from "../../common/mobile-summary";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
@@ -39,11 +39,7 @@ export default function MediaServerWidget({
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const t = useScopedI18n("widget.mediaRequests-requestList");
- const {
- data: mediaRequests,
- error,
- isPending,
- } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
+ const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
@@ -52,20 +48,11 @@ export default function MediaServerWidget({
recentDays: options.recentDays,
});
- if (displayMode === "mobileSummary" && isPending) return ;
- if (displayMode === "mobileSummary" && error && !mediaRequests) throw error;
if (!mediaRequests) return ;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
if (displayMode === "mobileSummary") {
- return (
-
- );
+ return ;
}
return (
@@ -244,8 +231,7 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
- size={44}
- aria-label={t("pending.approve")}
+ size="xs"
onClick={() => {
handleDecision("approve");
}}
@@ -258,8 +244,7 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
className="mediaRequests-list-item-pending-button-decline"
variant="light"
color="red"
- size={44}
- aria-label={t("pending.decline")}
+ size="xs"
onClick={() => {
handleDecision("decline");
}}
diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts
index 9dae1ea5c..c8824bebc 100644
--- a/packages/widgets/src/media-requests/list/index.ts
+++ b/packages/widgets/src/media-requests/list/index.ts
@@ -17,7 +17,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
supportsCompactSummary: true,
supportsDetailView: true,
},
- queryKey: [["widget", "mediaRequests"]],
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
@@ -39,7 +38,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
contextActions: ({ integrationIds }) => [
{
key: "search",
- mobileVisible: true,
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts
index d215eabac..d49cb8495 100644
--- a/packages/widgets/src/media-requests/stats/index.ts
+++ b/packages/widgets/src/media-requests/stats/index.ts
@@ -7,15 +7,12 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
- mobile: { supportsDetailView: true },
- queryKey: [["widget", "mediaRequests"]],
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
- mobileVisible: true,
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx
index 361b8ab7b..439857a8f 100644
--- a/packages/widgets/src/media-server/component.tsx
+++ b/packages/widgets/src/media-server/component.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
-import { Fragment, useCallback, useMemo } from "react";
+import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import {
IconDeviceTv,
@@ -24,47 +24,9 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
-export default function MediaServerWidget({ displayMode, ...props }: WidgetComponentProps<"mediaServer">) {
- if (displayMode === "mobileSummary") return ;
- return ;
-}
-
-const MediaServerMobileSummary = ({
- options,
- integrationIds,
-}: Pick, "options" | "integrationIds">) => {
- const t = useScopedI18n("widget.mediaServer");
- const { data, error, isPending } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
- integrationIds,
- showOnlyPlaying: options.showOnlyPlaying,
- });
-
- if (isPending) return ;
- if (error && !data) throw error;
-
- const currentStreams = data ?? [];
- const hasPartialFailure = currentStreams.length < integrationIds.length;
- const playingSessions = currentStreams
- .flatMap((stream) => stream.sessions)
- .filter((session) => session.currentlyPlaying !== null);
-
- return (
-
- );
-};
-
-const MediaServerTable = ({
- options,
- integrationIds,
-}: Pick, "options" | "integrationIds">) => {
+export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
@@ -115,9 +77,7 @@ const MediaServerTable = ({
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
- positionMs !== null && durationMs !== null
- ? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
- : null;
+ positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
return (
@@ -164,8 +124,8 @@ const MediaServerTable = ({
const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ??
- currentlyPlaying.metadata?.transcoding.target.audioCodec ??
- currentlyPlaying.metadata?.transcoding.container,
+ currentlyPlaying.metadata?.transcoding.target.audioCodec ??
+ currentlyPlaying.metadata?.transcoding.container,
);
return (
@@ -205,17 +165,6 @@ const MediaServerTable = ({
);
const { openModal } = useModalAction(ItemInfoModal);
- const openSession = useCallback(
- (session: StreamSession) => {
- openModal(
- { item: session },
- {
- title: session.sessionName,
- },
- );
- },
- [openModal],
- );
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
@@ -261,21 +210,17 @@ const MediaServerTable = ({
height: "100%",
},
},
- mantineTableBodyRowProps: ({ row }) => ({
- tabIndex: 0,
- "aria-haspopup": "dialog",
- "aria-label": row.original.currentlyPlaying
- ? `${row.original.sessionName}: ${row.original.currentlyPlaying.name}`
- : row.original.sessionName,
- onClick: () => openSession(row.original),
- onKeyDown: (event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- openSession(row.original);
+ mantineTableBodyCellProps: ({ row }) => ({
+ onClick: () => {
+ openModal(
+ {
+ item: row.original,
+ },
+ {
+ title: row.original.sessionName,
+ },
+ );
},
- style: { cursor: "pointer" },
- }),
- mantineTableBodyCellProps: () => ({
py: 4,
style: {
overflowX: "hidden",
@@ -285,7 +230,7 @@ const MediaServerTable = ({
});
const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
- const session = flatSessions.find((candidate) => candidate.integrationKind === kind);
+ const session = flatSessions.find((session) => session.integrationKind === kind);
return {
integrationKind: kind,
integrationIcon: session?.integrationIcon,
@@ -317,7 +262,7 @@ const MediaServerTable = ({
);
-};
+}
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts
index 34414e3b0..54f00476c 100644
--- a/packages/widgets/src/media-server/index.ts
+++ b/packages/widgets/src/media-server/index.ts
@@ -7,14 +7,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
- mobile: {
- width: 2,
- height: 1,
- supportsCompactSummary: true,
- supportsDetailView: true,
- unmountWhenOffscreen: true,
- },
- queryKey: [["widget", "mediaServer"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx
index 9fe017346..51962c00b 100644
--- a/packages/widgets/src/media-transcoding/component.tsx
+++ b/packages/widgets/src/media-transcoding/component.tsx
@@ -10,7 +10,7 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
-import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary";
+import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -40,13 +40,11 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: queuePage,
};
- const { data: transcodingData, error, isPending } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
+ const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
- if (displayMode === "mobileSummary" && isPending) return ;
- if (displayMode === "mobileSummary" && error && !transcodingData) throw error;
if (!transcodingData) return ;
if (displayMode === "mobileSummary") {
@@ -66,7 +64,6 @@ export default function MediaTranscodingWidget({
value={summary.value}
label={summary.label}
description={t(failedHealthChecks > 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")}
- isStale={Boolean(error)}
/>
);
}
diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts
index 8add1e086..95f0f0388 100644
--- a/packages/widgets/src/media-transcoding/index.ts
+++ b/packages/widgets/src/media-transcoding/index.ts
@@ -17,7 +17,6 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran
supportsCompactSummary: true,
supportsDetailView: true,
},
- queryKey: [["widget", "mediaTranscoding"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/notebook/component.tsx b/packages/widgets/src/notebook/component.tsx
index e8dc77498..84f3b1085 100644
--- a/packages/widgets/src/notebook/component.tsx
+++ b/packages/widgets/src/notebook/component.tsx
@@ -2,9 +2,6 @@
import dynamic from "next/dynamic";
-import { useScopedI18n } from "@homarr/translation/client";
-
-import { WidgetMobileSummary } from "../common/mobile-summary";
import type { WidgetComponentProps } from "../definition";
const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), {
@@ -12,23 +9,5 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note
});
export default function NotebookWidget(props: WidgetComponentProps<"notebook">) {
- const t = useScopedI18n("widget.notebook");
-
- if (props.displayMode === "mobileSummary") {
- const excerpt = props.options.content
- .replace(/