diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
index 6f67e977f..58940420f 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
@@ -1,8 +1,9 @@
"use client";
import type { CSSProperties, RefObject } from "react";
-import { useMemo, useRef } from "react";
+import { useMemo, useRef, useState } from "react";
import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
import { IconLayoutBoard } from "@tabler/icons-react";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
@@ -63,6 +64,8 @@ const getMobilePresentation = (item: SectionItem) => {
const MobileBoardItem = ({ item }: { item: SectionItem }) => {
const widgetStateRef = useRef
| null>(null);
const presentation = getMobilePresentation(item);
+ const [isNearViewport, setIsNearViewport] = useState(presentation.eager);
+ const [detailsOpened, detailsDisclosure] = useDisclosure(false);
return (
{
data-mobile-board-item={item.id}
data-mobile-display-mode={presentation.displayMode}
>
-
+
-
+
);
};
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..45e59a506 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import {
isMobileContextActionVisible,
resolveMobileItemPresentation,
+ shouldKeepMobileWidgetActionsMounted,
shouldRenderMobileWidgetActions,
} from "./mobile-presentation";
@@ -42,7 +43,7 @@ describe("resolveMobileItemPresentation", () => {
});
});
- test("keeps automatic footprint defaults when only details are configured", () => {
+ test("uses a compact one-row fallback when only details are configured", () => {
expect(
resolveMobileItemPresentation(
{ kind: "calendar", height: 7 },
@@ -52,8 +53,10 @@ describe("resolveMobileItemPresentation", () => {
),
).toMatchObject({
width: 2,
- height: 3,
+ height: 1,
+ displayMode: "mobileSummary",
supportsDetails: true,
+ usesGenericSummary: true,
});
});
@@ -75,6 +78,7 @@ describe("resolveMobileItemPresentation", () => {
height: 1,
displayMode: "mobileSummary",
supportsDetails: false,
+ usesGenericSummary: false,
eager: true,
unmountWhenOffscreen: false,
});
@@ -101,6 +105,42 @@ describe("shouldRenderMobileWidgetActions", () => {
});
});
+describe("shouldKeepMobileWidgetActionsMounted", () => {
+ test("does not expose actions before a deferred widget is near the viewport", () => {
+ expect(
+ shouldKeepMobileWidgetActionsMounted({
+ isNearViewport: false,
+ actionsOpened: false,
+ detailsOpened: false,
+ isOpeningDetails: false,
+ isCompletingAction: false,
+ actionTriggerHasFocus: false,
+ }),
+ ).toBe(false);
+ });
+
+ test.each([
+ ["near the viewport", { isNearViewport: true }],
+ ["showing its action drawer", { actionsOpened: true }],
+ ["showing its detail view", { detailsOpened: true }],
+ ["transitioning to details", { isOpeningDetails: true }],
+ ["completing an action", { isCompletingAction: true }],
+ ["retaining keyboard focus", { actionTriggerHasFocus: true }],
+ ])("keeps actions mounted while %s", (_label, activeState) => {
+ expect(
+ shouldKeepMobileWidgetActionsMounted({
+ isNearViewport: false,
+ actionsOpened: false,
+ detailsOpened: false,
+ isOpeningDetails: false,
+ isCompletingAction: false,
+ actionTriggerHasFocus: false,
+ ...activeState,
+ }),
+ ).toBe(true);
+ });
+});
+
describe("isMobileContextActionVisible", () => {
test("requires actions to opt into the read-only mobile surface", () => {
const action = { key: "configure", label: "Configure", onClick: () => undefined };
diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
index 41917e4a1..08125d5bf 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
@@ -4,14 +4,21 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
mobile: WidgetMobilePresentation | undefined,
-) => ({
- 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,
- eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
- unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
-});
+) => {
+ const supportsCompactSummary = mobile?.supportsCompactSummary === true;
+ const supportsDetails = mobile?.supportsDetailView === true;
+ const usesGenericSummary = supportsDetails && !supportsCompactSummary;
+
+ return {
+ width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
+ height: mobile?.height ?? (item.kind === "app" || usesGenericSummary ? 1 : Math.max(1, Math.min(item.height, 3))),
+ displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const),
+ supportsDetails,
+ usesGenericSummary,
+ eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
+ unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false),
+ };
+};
export const shouldRenderMobileWidgetActions = ({
supportsDetails,
@@ -23,5 +30,24 @@ export const shouldRenderMobileWidgetActions = ({
visibleContextActionCount: number;
}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0;
+interface ShouldKeepMobileWidgetActionsMountedInput {
+ isNearViewport: boolean;
+ actionsOpened: boolean;
+ detailsOpened: boolean;
+ isOpeningDetails: boolean;
+ isCompletingAction: boolean;
+ actionTriggerHasFocus: boolean;
+}
+
+export const shouldKeepMobileWidgetActionsMounted = ({
+ isNearViewport,
+ actionsOpened,
+ detailsOpened,
+ isOpeningDetails,
+ isCompletingAction,
+ actionTriggerHasFocus,
+}: ShouldKeepMobileWidgetActionsMountedInput) =>
+ isNearViewport || actionsOpened || detailsOpened || isOpeningDetails || isCompletingAction || actionTriggerHasFocus;
+
export const isMobileContextActionVisible = (action: WidgetContextMenuAction) =>
action.mobileVisible === true && !action.hidden;
diff --git a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts
new file mode 100644
index 000000000..6ad42dd38
--- /dev/null
+++ b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts
@@ -0,0 +1,103 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+import { getMobilePreloadRootMargin, observeMobileViewportProximity } from "./mobile-viewport-observer";
+
+class IntersectionObserverMock {
+ static instances: IntersectionObserverMock[] = [];
+
+ readonly observedElements = new Set();
+ readonly rootMargin: string;
+ disconnected = false;
+
+ constructor(
+ private readonly callback: IntersectionObserverCallback,
+ options?: IntersectionObserverInit,
+ ) {
+ this.rootMargin = options?.rootMargin ?? "0px";
+ IntersectionObserverMock.instances.push(this);
+ }
+
+ observe(element: Element) {
+ this.observedElements.add(element);
+ }
+
+ unobserve(element: Element) {
+ this.observedElements.delete(element);
+ }
+
+ disconnect() {
+ this.disconnected = true;
+ this.observedElements.clear();
+ }
+
+ emit(element: Element, isIntersecting: boolean) {
+ this.callback([{ target: element, isIntersecting } as IntersectionObserverEntry], this as never);
+ }
+}
+
+describe("observeMobileViewportProximity", () => {
+ beforeEach(() => {
+ IntersectionObserverMock.instances = [];
+ vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 600, writable: true });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test("shares one observer across cards and recreates it once when the viewport margin changes", () => {
+ const firstElement = document.createElement("div");
+ const secondElement = document.createElement("div");
+ const firstListener = vi.fn();
+ const secondListener = vi.fn();
+
+ const stopObservingFirst = observeMobileViewportProximity(firstElement, firstListener);
+ const stopObservingSecond = observeMobileViewportProximity(secondElement, secondListener);
+
+ expect(IntersectionObserverMock.instances).toHaveLength(1);
+ expect(IntersectionObserverMock.instances[0]?.observedElements).toEqual(new Set([firstElement, secondElement]));
+ expect(IntersectionObserverMock.instances[0]?.rootMargin).toBe(getMobilePreloadRootMargin(600));
+
+ IntersectionObserverMock.instances[0]?.emit(firstElement, true);
+ expect(firstListener).toHaveBeenCalledWith(true);
+ expect(secondListener).not.toHaveBeenCalled();
+
+ window.dispatchEvent(new Event("resize"));
+ expect(IntersectionObserverMock.instances).toHaveLength(1);
+
+ window.innerHeight = 700;
+ window.dispatchEvent(new Event("resize"));
+
+ expect(IntersectionObserverMock.instances).toHaveLength(2);
+ expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true);
+ expect(IntersectionObserverMock.instances[1]?.observedElements).toEqual(new Set([firstElement, secondElement]));
+ expect(IntersectionObserverMock.instances[1]?.rootMargin).toBe(getMobilePreloadRootMargin(700));
+
+ stopObservingFirst();
+ stopObservingSecond();
+ expect(IntersectionObserverMock.instances[1]?.disconnected).toBe(true);
+ });
+
+ test("removes one-time subscriptions after their card first becomes near", () => {
+ const element = document.createElement("div");
+ const listener = vi.fn();
+
+ const stopObserving = observeMobileViewportProximity(element, listener, { once: true });
+ IntersectionObserverMock.instances[0]?.emit(element, true);
+
+ expect(listener).toHaveBeenCalledOnce();
+ expect(IntersectionObserverMock.instances[0]?.disconnected).toBe(true);
+ stopObserving();
+ });
+
+ test("mounts content immediately when IntersectionObserver is unavailable", () => {
+ vi.stubGlobal("IntersectionObserver", undefined);
+ const listener = vi.fn();
+
+ const stopObserving = observeMobileViewportProximity(document.createElement("div"), listener);
+
+ expect(listener).toHaveBeenCalledWith(true);
+ stopObserving();
+ });
+});
diff --git a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts
new file mode 100644
index 000000000..61c657269
--- /dev/null
+++ b/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts
@@ -0,0 +1,116 @@
+export const mobilePreloadViewportMultiplier = 2;
+
+export const getMobilePreloadRootMargin = (viewportHeight: number) =>
+ `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
+
+interface MobileViewportSubscription {
+ listener: (isNearViewport: boolean) => void;
+ once: boolean;
+}
+
+interface ObserveMobileViewportOptions {
+ once?: boolean;
+}
+
+const subscriptions = new Map>();
+
+let observer: IntersectionObserver | null = null;
+let observedRootMargin: string | null = null;
+let isListeningForResize = false;
+
+const stopObservingIfIdle = () => {
+ if (subscriptions.size > 0) return;
+
+ observer?.disconnect();
+ observer = null;
+ observedRootMargin = null;
+
+ if (isListeningForResize) {
+ window.removeEventListener("resize", handleViewportResize);
+ isListeningForResize = false;
+ }
+};
+
+const removeSubscription = (element: Element, subscription: MobileViewportSubscription) => {
+ const elementSubscriptions = subscriptions.get(element);
+ if (!elementSubscriptions) return;
+
+ elementSubscriptions.delete(subscription);
+ if (elementSubscriptions.size > 0) return;
+
+ subscriptions.delete(element);
+ observer?.unobserve(element);
+ stopObservingIfIdle();
+};
+
+const handleIntersections: IntersectionObserverCallback = (entries) => {
+ for (const entry of entries) {
+ const elementSubscriptions = subscriptions.get(entry.target);
+ if (!elementSubscriptions) continue;
+
+ for (const subscription of elementSubscriptions) {
+ subscription.listener(entry.isIntersecting);
+ if (entry.isIntersecting && subscription.once) {
+ removeSubscription(entry.target, subscription);
+ }
+ }
+ }
+};
+
+const createObserver = (rootMargin: string) => {
+ observer?.disconnect();
+ observer = new IntersectionObserver(handleIntersections, { rootMargin });
+ observedRootMargin = rootMargin;
+
+ for (const element of subscriptions.keys()) {
+ observer.observe(element);
+ }
+};
+
+function handleViewportResize() {
+ const rootMargin = getMobilePreloadRootMargin(window.innerHeight);
+ if (rootMargin === observedRootMargin) return;
+
+ createObserver(rootMargin);
+}
+
+const ensureObserver = () => {
+ if (observer) return;
+
+ createObserver(getMobilePreloadRootMargin(window.innerHeight));
+ window.addEventListener("resize", handleViewportResize);
+ isListeningForResize = true;
+};
+
+export const observeMobileViewportProximity = (
+ element: Element,
+ listener: (isNearViewport: boolean) => void,
+ options: ObserveMobileViewportOptions = {},
+) => {
+ if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") {
+ listener(true);
+ return () => undefined;
+ }
+
+ const subscription: MobileViewportSubscription = {
+ listener,
+ once: options.once ?? false,
+ };
+ const elementSubscriptions = subscriptions.get(element);
+
+ if (elementSubscriptions) {
+ elementSubscriptions.add(subscription);
+ } else {
+ subscriptions.set(element, new Set([subscription]));
+ }
+
+ if (observer) {
+ observer.observe(element);
+ } else {
+ ensureObserver();
+ }
+
+ return () => {
+ removeSubscription(element, subscription);
+ };
+};
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..312a60945 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, useRef, useState } 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";
@@ -20,20 +20,40 @@ import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { useHasNestedDialog } from "./mobile-dialog-stack";
import classes from "./mobile-board.module.css";
-import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation";
+import {
+ isMobileContextActionVisible,
+ shouldKeepMobileWidgetActionsMounted,
+ shouldRenderMobileWidgetActions,
+} from "./mobile-presentation";
interface MobileWidgetActionsProps {
item: SectionItem;
supportsDetails: boolean;
+ detailsFromCard: boolean;
+ detailsOpened: boolean;
+ openDetails: () => void;
+ closeDetails: () => void;
widgetStateRef: MutableRefObject | null>;
+ isNearViewport: boolean;
}
-export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
+export const MobileWidgetActions = ({
+ item,
+ supportsDetails,
+ detailsFromCard,
+ detailsOpened,
+ openDetails,
+ closeDetails,
+ widgetStateRef,
+ isNearViewport,
+}: MobileWidgetActionsProps) => {
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
- const [detailsOpened, detailsDisclosure] = useDisclosure(false);
+ const [isOpeningDetails, setIsOpeningDetails] = useState(false);
+ const [isCompletingAction, setIsCompletingAction] = useState(false);
+ const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false);
const actionTriggerRef = useRef(null);
const detailsDialogRef = useRef(null);
- const openDetailsAfterActionsRef = useRef(false);
+ const pendingContextActionRef = useRef<(() => void) | null>(null);
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
const t = useI18n();
const settings = useSettings();
@@ -59,133 +79,169 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const hasActions = shouldRenderMobileWidgetActions({
- supportsDetails,
+ supportsDetails: supportsDetails && !detailsFromCard,
supportsRefresh: queryKey !== undefined,
visibleContextActionCount: visibleContextActions.length,
});
+ const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({
+ isNearViewport,
+ actionsOpened,
+ detailsOpened,
+ isOpeningDetails,
+ isCompletingAction,
+ actionTriggerHasFocus,
+ });
- if (!hasActions) return null;
+ if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null;
return (
<>
-
-
-
+ {hasActions && (
+ <>
+ setActionTriggerHasFocus(true)}
+ onBlur={() => setActionTriggerHasFocus(false)}
+ >
+
+
- {
- if (!openDetailsAfterActionsRef.current) return;
- openDetailsAfterActionsRef.current = false;
- detailsDisclosure.open();
- }}
- closeButtonProps={{
- "aria-label": t("common.action.close"),
- size: 44,
- }}
- >
-
- {supportsDetails && (
- }
- onClick={() => {
- openDetailsAfterActionsRef.current = true;
- actionsDisclosure.close();
- }}
- >
- {t("board.mobile.details")}
-
- )}
- {visibleContextActions.map((action) => {
- const Icon = action.icon;
- return (
- : undefined}
- onClick={() => {
- action.onClick();
- actionsDisclosure.close();
- }}
- >
- {String(translateIfNecessary(t, action.label))}
-
- );
- })}
- {queryKey && }
-
-
+ {
+ if (isOpeningDetails) {
+ setIsOpeningDetails(false);
+ openDetails();
+ return;
+ }
- 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,
- overflowY: "auto",
- paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
- },
- }}
- >
-
-
-
-
+ const pendingContextAction = pendingContextActionRef.current;
+ if (pendingContextAction) {
+ pendingContextActionRef.current = null;
+ setIsCompletingAction(false);
+ pendingContextAction();
+ return;
+ }
+
+ actionTriggerRef.current?.focus();
+ }}
+ closeButtonProps={{
+ "aria-label": t("common.action.close"),
+ size: 44,
+ }}
+ >
+
+ {supportsDetails && !detailsFromCard && (
+ }
+ onClick={() => {
+ setIsOpeningDetails(true);
+ actionsDisclosure.close();
+ }}
+ >
+ {t("board.mobile.details")}
+
+ )}
+ {visibleContextActions.map((action) => {
+ const Icon = action.icon;
+ return (
+ : undefined}
+ onClick={() => {
+ pendingContextActionRef.current = action.onClick;
+ setIsCompletingAction(true);
+ actionsDisclosure.close();
+ }}
+ >
+ {String(translateIfNecessary(t, action.label))}
+
+ );
+ })}
+ {queryKey && }
+
+
+ >
+ )}
+
+ {supportsDetails && (
+ {
+ if (!detailsFromCard) {
+ 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,
+ overflowY: "auto",
+ paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
+ },
+ }}
+ >
+
+
+
+
+ )}
>
);
};
diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
index 906abdea6..7a17620ea 100644
--- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
-import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,6 +18,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
+ const currentLayoutId = useCurrentLayout();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -104,7 +105,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
- const currentLayoutId = getCurrentLayout(board);
const appIds = filterByItemKind(
board.items.filter(
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
@@ -130,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
- }, [category, board, t, openConfirmModal, settings]);
+ }, [category, board, currentLayoutId, t, openConfirmModal, settings]);
return {
addCategoryAbove,
diff --git a/apps/nextjs/src/components/board/sections/dynamic-section.tsx b/apps/nextjs/src/components/board/sections/dynamic-section.tsx
index 8bb0a52c3..c599f607e 100644
--- a/apps/nextjs/src/components/board/sections/dynamic-section.tsx
+++ b/apps/nextjs/src/components/board/sections/dynamic-section.tsx
@@ -1,7 +1,7 @@
import { Badge, Box, Card } from "@mantine/core";
import combineClasses from "clsx";
-import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,7 +14,6 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
- const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -52,8 +51,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
{options.title}
)}
- {/* Use unique key by layout to reinitialize gridstack */}
-
+
diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
index e851e5c2a..1688f6544 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
+++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
@@ -4,6 +4,8 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
+import { useCurrentLayout } from "@homarr/boards/context";
+
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
@@ -15,6 +17,12 @@ interface Props extends BoxProps {
}
export const GridStack = ({ section, ...props }: Props) => {
+ const currentLayoutId = useCurrentLayout();
+
+ return ;
+};
+
+const GridStackForLayout = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
index b5e468de6..4b3c7d33f 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
+++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
@@ -103,6 +103,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
// Updates the react-query state
moveAndResizeItem({
itemId: id,
+ layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -120,6 +121,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
if (type === "section") {
moveAndResizeInnerSection({
innerSectionId: id,
+ layoutId: currentLayoutId,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -136,7 +138,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
- [moveAndResizeItem, moveAndResizeInnerSection],
+ [currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -149,6 +151,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
// Updates the react-query state
moveItemToSection({
itemId: id,
+ layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -167,6 +170,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
if (type === "section") {
moveInnerSectionToSection({
innerSectionId: id,
+ layoutId: currentLayoutId,
sectionId: section.id,
// We want the following properties to be null by default
// so the next free position is used from the gridstack
@@ -184,7 +188,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
- [moveItemToSection, moveInnerSectionToSection, section.id],
+ [currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
);
// initialize the gridstack
diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx
index f61f51552..3db4a7924 100644
--- a/apps/nextjs/src/components/board/sections/section-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/section-actions.tsx
@@ -1,10 +1,10 @@
import { useCallback } from "react";
-import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
innerSectionId: string;
+ layoutId: string;
xOffset: number;
yOffset: number;
width: number;
@@ -12,6 +12,7 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
+ layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -23,7 +24,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
- ({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
+ ({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -31,12 +32,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
@@ -50,7 +49,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
- ({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
+ ({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -59,12 +58,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== layoutId) return layout;
return {
...layout,
...positionProps,
diff --git a/apps/nextjs/src/components/board/use-mobile-board.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts
index 35a8a95b6..df8827ac5 100644
--- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts
+++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
-import { resolveMobileBoardViewport } from "./use-mobile-board";
+import {
+ resolveIsAutomaticMobileBoard,
+ resolveMobileBoardViewport,
+ shouldShowMobileBoardViewportSkeleton,
+} from "./use-mobile-board";
describe("resolveMobileBoardViewport", () => {
test("keeps phones mobile regardless of their viewport width", () => {
@@ -56,3 +60,26 @@ describe("resolveMobileBoardViewport", () => {
});
});
});
+
+describe("resolveIsAutomaticMobileBoard", () => {
+ test.each([
+ [true, true, true],
+ [true, false, false],
+ [false, true, false],
+ [false, false, false],
+ ])(
+ "requires both a mobile viewport and the server setting (viewport: %s, setting: %s)",
+ (isMobileViewport, enableAutomaticMobileLayout, expected) => {
+ expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected);
+ },
+ );
+
+ test("does not show the tablet resolution skeleton in legacy mode", () => {
+ expect(
+ shouldShowMobileBoardViewportSkeleton({
+ enableAutomaticMobileLayout: false,
+ isResolved: false,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx
index 7197aac22..1972206f2 100644
--- a/apps/nextjs/src/components/board/use-mobile-board.tsx
+++ b/apps/nextjs/src/components/board/use-mobile-board.tsx
@@ -5,6 +5,8 @@ import { createContext, useContext, useEffect, useState } from "react";
import { Box, Group, Skeleton, Stack } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
+import { useSettings } from "@homarr/settings";
+
export const mobileBoardMediaQuery = "(max-width: 48em)";
export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop";
@@ -70,6 +72,7 @@ export const MobileBoardViewportProvider = ({
initialDeviceClass,
children,
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
+ const { enableAutomaticMobileLayout } = useSettings();
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
getInitialValueInEffect: true,
@@ -87,11 +90,42 @@ export const MobileBoardViewportProvider = ({
return (
- {viewport.isResolved ? children : }
+ {shouldShowMobileBoardViewportSkeleton({
+ enableAutomaticMobileLayout,
+ isResolved: viewport.isResolved,
+ }) ? (
+
+ ) : (
+ children
+ )}
);
};
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
-export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
+export const resolveIsAutomaticMobileBoard = ({
+ isMobileViewport,
+ enableAutomaticMobileLayout,
+}: {
+ isMobileViewport: boolean;
+ enableAutomaticMobileLayout: boolean;
+}) => isMobileViewport && enableAutomaticMobileLayout;
+
+export const shouldShowMobileBoardViewportSkeleton = ({
+ enableAutomaticMobileLayout,
+ isResolved,
+}: {
+ enableAutomaticMobileLayout: boolean;
+ isResolved: boolean;
+}) => enableAutomaticMobileLayout && !isResolved;
+
+export const useIsMobileBoard = () => {
+ const viewport = useMobileBoardViewport();
+ const { enableAutomaticMobileLayout } = useSettings();
+
+ return resolveIsAutomaticMobileBoard({
+ isMobileViewport: viewport.isMobile,
+ enableAutomaticMobileLayout,
+ });
+};
diff --git a/apps/nextjs/src/components/language/current-language-combobox.tsx b/apps/nextjs/src/components/language/current-language-combobox.tsx
index 54d9a0f98..9a8dbcb52 100644
--- a/apps/nextjs/src/components/language/current-language-combobox.tsx
+++ b/apps/nextjs/src/components/language/current-language-combobox.tsx
@@ -1,5 +1,6 @@
"use client";
+import { requestBoardEditAction } from "@homarr/boards/edit-mode";
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import { LanguageCombobox } from "./language-combobox";
@@ -16,7 +17,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage
return (
requestBoardEditAction(() => changeLocale(locale))}
isPending={isPending}
width={width}
withinPortal={withinPortal}
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index cf6d013a2..2201a658f 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -17,8 +17,10 @@ import {
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
+import { requestBoardEditAction } from "@homarr/boards/edit-mode";
import { hotkeys } from "@homarr/definitions";
import { createModal, useModalAction } from "@homarr/modals";
+import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -47,22 +49,36 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
- await signOut({
- redirect: false,
- });
- openModal({
- onTimeout: () => {
- if (logoutUrl) {
- window.location.assign(logoutUrl);
- return;
- }
- router.push("/auth/login");
- },
- });
- }, [logoutUrl, openModal, router]);
+ try {
+ const response = await signOut({
+ redirect: false,
+ });
+ if (typeof response.url !== "string" || response.url.length === 0) {
+ throw new Error("Sign out did not return a success URL");
+ }
+
+ return await new Promise((resolve) => {
+ openModal({
+ onTimeout: () => {
+ resolve(true);
+ if (logoutUrl) {
+ window.location.replace(logoutUrl);
+ return;
+ }
+ router.replace("/auth/login");
+ },
+ });
+ });
+ } catch {
+ showErrorNotification({
+ title: t("logoutError.title"),
+ message: t("logoutError.message"),
+ });
+ return false;
+ }
+ }, [logoutUrl, openModal, router, t]);
return (
- // We use keepMounted so we can add event listeners to prevent navigating away without saving the board