Revert "feat: refine mobile dashboard experience"

This reverts commit 315520beca.
This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent e9853f1fb1
commit 60ea03e72e
35 files changed
+110 -989

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ The main canvas and enabled rails stay at least one viewport tall, and the canva
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
@@ -1,17 +0,0 @@
import { describe, expect, test } from "vitest";
import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy";
describe("shouldPrefetchWidgetForRequest", () => {
test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => {
expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false);
});
test("keeps downloads prefetched for desktop requests", () => {
expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true);
});
test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => {
expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true);
});
});
@@ -1,7 +0,0 @@
import type { WidgetKind } from "@homarr/definitions";
export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => {
const isMobileDevice = deviceType === "mobile" || deviceType === "tablet";
return kind !== "downloads" || !isMobileDevice;
};
@@ -8,22 +8,12 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { BoardLogo } from "~/components/layout/logo/board-logo";
export const BoardSwitcherLogo = () => {
const board = useRequiredBoard();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const t = useI18n();
const isMobileBoard = useIsMobileBoard();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isMobileBoard });
if (!isMobileBoard) {
return (
<UnstyledButton component={Link} href="/">
<BoardLogoWithTitle size="md" hideTitleOnMobile />
</UnstyledButton>
);
}
return (
<Menu position="bottom-start" width={280}>
@@ -36,7 +26,7 @@ export const BoardSwitcherLogo = () => {
>
<Group gap="xs" wrap="nowrap">
<BoardLogo size={28} />
<Text fw={600} truncate title={board.name}>
<Text fw={600} truncate>
{board.name}
</Text>
<IconChevronDown size={16} aria-hidden />
@@ -56,9 +46,7 @@ export const BoardSwitcherLogo = () => {
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
aria-current={isCurrent ? "page" : undefined}
>
<Text truncate title={availableBoard.name}>
{availableBoard.name}
</Text>
{availableBoard.name}
</Menu.Item>
);
})}
@@ -1,18 +0,0 @@
import { describe, expect, test } from "vitest";
import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item";
describe("getMobilePreloadRootMargin", () => {
test.each([
[568, "1136px 0px"],
[844, "1688px 0px"],
[932, "1864px 0px"],
])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => {
expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected);
});
test("keeps the preload policy explicit and prevents negative margins", () => {
expect(mobilePreloadViewportMultiplier).toBe(2);
expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px");
});
});
@@ -8,25 +8,14 @@ import classes from "./mobile-board.module.css";
interface DeferredMobileItemProps extends PropsWithChildren {
eager: boolean;
unmountWhenOffscreen: boolean;
}
export const mobilePreloadViewportMultiplier = 2;
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => {
export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => {
const [hasMounted, setHasMounted] = useState(eager);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hasMountedOnceRef = useRef(eager);
useEffect(() => {
if (eager) {
hasMountedOnceRef.current = true;
setHasMounted(true);
return;
}
if (eager || hasMounted) return;
const element = wrapperRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
@@ -34,37 +23,17 @@ export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: De
return;
}
let observer: IntersectionObserver | null = null;
const observe = () => {
if (hasMountedOnceRef.current && !unmountWhenOffscreen) return;
observer?.disconnect();
observer = new IntersectionObserver(
([entry]) => {
const isNearViewport = entry?.isIntersecting ?? false;
if (isNearViewport) {
hasMountedOnceRef.current = true;
setHasMounted(true);
if (!unmountWhenOffscreen) observer?.disconnect();
return;
}
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
setHasMounted(false);
}
},
{ rootMargin: getMobilePreloadRootMargin(window.innerHeight) },
);
observer.observe(element);
};
observe();
window.addEventListener("resize", observe);
return () => {
window.removeEventListener("resize", observe);
observer?.disconnect();
};
}, [eager, unmountWhenOffscreen]);
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setHasMounted(true);
observer.disconnect();
},
{ rootMargin: "200% 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [eager, hasMounted]);
return (
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
@@ -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;
}
@@ -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}
>
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
<DeferredMobileItem eager={presentation.eager}>
<BoardItemContent
item={item}
displayMode={presentation.displayMode}
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
<MobileWidgetActions
item={item}
supportsDetails={presentation.supportsDetails}
widgetStateRef={widgetStateRef}
/>
</DeferredMobileItem>
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
</Box>
);
};
@@ -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 = () => {
}}
>
<Box className={classes.stream} data-mobile-board>
<Title order={1} className={classes.boardTitle}>
{board.name}
</Title>
{elements.length === 0 ? (
<Center className={classes.empty}>
<Stack align="center" gap="xs" ta="center" maw={360}>
@@ -122,7 +120,7 @@ export const MobileBoard = () => {
{t("board.mobile.empty.title")}
</Title>
<Text c="dimmed" size="sm">
{t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
{t("board.mobile.empty.description")}
</Text>
</Stack>
</Center>
@@ -132,12 +130,10 @@ export const MobileBoard = () => {
<Title
key={block.id}
id={block.anchorId}
order={block.headingLevel}
size={block.headingLevel === 2 ? "h4" : block.headingLevel === 3 ? "h5" : "h6"}
fw={block.headingLevel === 2 ? 700 : 600}
order={2}
size="h4"
className={classes.sectionHeading}
tabIndex={-1}
style={{ marginInlineStart: Math.max(0, block.headingLevel - 2) * 12 }}
>
{block.title}
</Title>
@@ -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);
});
});
@@ -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<HTMLElement>('[role="dialog"]') ?? element.querySelector<HTMLElement>('[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<HTMLDivElement | null>) => {
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;
};
@@ -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,
});
});
});
@@ -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 = <TLayout extends PositionedLayout>(
(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,
@@ -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);
});
});
@@ -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;
@@ -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);
});
});
@@ -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;
};
@@ -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<HTMLButtonElement | null>(null);
const detailsDialogRef = useRef<HTMLDivElement | null>(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<string, unknown>,
[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<string, unknown>) =>
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 (
<>
<ActionIcon
ref={actionTriggerRef}
className={classes.widgetAction}
variant="filled"
color="dark"
radius="xl"
size={44}
aria-label={t("board.mobile.actions", { title })}
aria-label={t("board.mobile.actions")}
onClick={actionsDisclosure.open}
>
<IconDotsVertical size={20} />
@@ -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,
}}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
{supportsDetails && (
@@ -113,8 +103,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
size="lg"
leftSection={<IconMaximize size={20} />}
onClick={() => {
openDetailsAfterActionsRef.current = true;
actionsDisclosure.close();
detailsDisclosure.open();
}}
>
{t("board.mobile.details")}
@@ -140,74 +130,37 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
</Button>
);
})}
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={refresh}
>
{t("item.menu.label.refresh")}
</Button>
</Stack>
</Drawer>
<Modal
ref={detailsDialogRef}
opened={detailsOpened}
onClose={detailsDisclosure.close}
title={title}
fullScreen
padding="md"
returnFocus={false}
trapFocus={!hasNestedDialog}
closeOnEscape={!hasNestedDialog}
onExitTransitionEnd={() => 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))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent
item={item}
displayMode="mobileDetail"
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu widgetStateRef={widgetStateRef} />
</Box>
</Modal>
</>
);
};
const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => {
const t = useI18n();
const queryClient = useQueryClient();
const isFetching = useIsFetching({ queryKey }) > 0;
return (
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={() => {
void queryClient.refetchQueries({ queryKey, type: "all" });
onComplete();
}}
>
{t("item.menu.label.refresh")}
</Button>
);
};
-243
View File
@@ -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: "<p>Mobile notes</p>" } : {}),
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<HTMLElement>("[data-mobile-board] > div"));
const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid");
const items = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]"));
const focusable = Array.from(
document.querySelectorAll<HTMLElement>(
'[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<HTMLElement>("[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);
});
@@ -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([]);
});
});
@@ -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 (
<Stack gap="xs">
@@ -40,24 +34,22 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size={44}
size="sm"
disabled={clampedPage === 0}
aria-label={tCommon("action.previous")}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<PreviousIcon size={14} />
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size={44}
size="sm"
disabled={clampedPage >= totalPages - 1}
aria-label={tCommon("action.next")}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<NextIcon size={14} />
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
-1
View File
@@ -6,7 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: true,
icon: IconBadgeCc,
queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
maxIntegrations: 1,
@@ -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 <WidgetMobileLoading />;
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 (
<WidgetMobileSummary
value={systems.length}
label={label}
description={t("board.mobile.systemStatus", {
up,
attention: systems.length - up,
})}
isStale={failedResults.length > 0}
/>
);
};
+11 -39
View File
@@ -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 (
<Center h="100%" p="sm">
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
<Text fz="xl" fw={700} lh={1.15} ta="center" w="100%" lineClamp={2} style={{ overflowWrap: "anywhere" }}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1} ta="center" w="100%">
{label}
</Text>
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
<Text fz="xl" fw={700} lh={1}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{label}
</Text>
</Group>
{description !== undefined && (
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1} ta="center" w="100%">
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
{description}
</Text>
)}
{isStale && (
<Group component="output" gap={4} justify="center" wrap="nowrap" w="100%">
<IconAlertTriangle
size={14}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
aria-hidden
/>
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1}>
{t("board.mobile.dataWarning")}
</Text>
</Group>
)}
</Stack>
</Center>
);
}
export function WidgetMobileLoading() {
const t = useI18n();
return (
<Center component="output" h="100%" aria-label={t("common.action.loading")}>
<Loader size="sm" />
</Center>
);
}
@@ -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 ? (
<Stack gap={4}>
{applications.map((app) => (
<ResourceRow
key={app.uuid}
item={app}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="application"
isMobileDetail={isMobileDetail}
/>
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
))}
</Stack>
) : (
-2
View File
@@ -6,8 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: true,
icon: IconCloud,
mobile: { supportsDetailView: true },
queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
-2
View File
@@ -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: "" }),
@@ -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,
}}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} −`}
onClick={() => hoursHandlers.current?.decrement()}
>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
–
</ActionIcon>
<NumberInput
@@ -67,26 +58,15 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={999}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.hours")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} +`}
onClick={() => hoursHandlers.current?.increment()}
>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} −`}
onClick={() => minutesHandlers.current?.decrement()}
>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
–
</ActionIcon>
<NumberInput
@@ -97,15 +77,9 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={59}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.minutes")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} +`}
onClick={() => minutesHandlers.current?.increment()}
>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
+
</ActionIcon>
</Group>
@@ -117,7 +91,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
mih={44}
h="2rem"
w="12rem"
onClick={handleSetTimer}
>
@@ -10,7 +10,6 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
mobile: { supportsDetailView: true },
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
createOptions() {
-7
View File
@@ -25,13 +25,6 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {
@@ -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(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&(?:nbsp|#160);/gi, " ")
.replace(/&(?:amp|#38);/gi, "&")
.replace(/&(?:lt|#60);/gi, "<")
.replace(/&(?:gt|#62);/gi, ">")
.replace(/&(?:quot|#34);/gi, '"')
.replace(/\s+/g, " ")
.trim();
return <WidgetMobileSummary value={excerpt || "—"} label={t("name")} />;
}
return <Notebook {...props} />;
}
-6
View File
@@ -6,12 +6,6 @@ import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
icon: IconNotes,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -6,7 +6,6 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
supportsAdvancedFocus: false,
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
+12 -38
View File
@@ -9,8 +9,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: true,
icon: IconShieldCheck,
mobile: { supportsDetailView: true },
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
maxIntegrations: 1,
@@ -192,42 +190,18 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon"
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
hostsNeedingUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
securityUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsWithSecurityUpdatesCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsThresholdMode: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsWarningAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
upToDateHostsCriticalAt: {
shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
},
hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
@@ -7,13 +7,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
supportsAdvancedFocus: true,
icon: IconServer2,
mobile: {
width: 2,
height: 1,
supportsCompactSummary: true,
supportsDetailView: true,
unmountWhenOffscreen: true,
},
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
-2
View File
@@ -8,8 +8,6 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
supportsAdvancedFocus: false,
icon: IconShieldLock,
mobile: { supportsDetailView: true },
queryKey: [["widget", "vpn"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(() => ({}));