diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
index 58940420f..6f67e977f 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx
+++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx
@@ -1,9 +1,8 @@
"use client";
import type { CSSProperties, RefObject } from "react";
-import { useMemo, useRef, useState } from "react";
+import { useMemo, useRef } 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";
@@ -64,8 +63,6 @@ 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 45e59a506..a145bc20b 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
import {
isMobileContextActionVisible,
resolveMobileItemPresentation,
- shouldKeepMobileWidgetActionsMounted,
shouldRenderMobileWidgetActions,
} from "./mobile-presentation";
@@ -43,7 +42,7 @@ describe("resolveMobileItemPresentation", () => {
});
});
- test("uses a compact one-row fallback when only details are configured", () => {
+ test("keeps automatic footprint defaults when only details are configured", () => {
expect(
resolveMobileItemPresentation(
{ kind: "calendar", height: 7 },
@@ -53,10 +52,8 @@ describe("resolveMobileItemPresentation", () => {
),
).toMatchObject({
width: 2,
- height: 1,
- displayMode: "mobileSummary",
+ height: 3,
supportsDetails: true,
- usesGenericSummary: true,
});
});
@@ -78,7 +75,6 @@ describe("resolveMobileItemPresentation", () => {
height: 1,
displayMode: "mobileSummary",
supportsDetails: false,
- usesGenericSummary: false,
eager: true,
unmountWhenOffscreen: false,
});
@@ -105,42 +101,6 @@ 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 08125d5bf..41917e4a1 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
+++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts
@@ -4,21 +4,14 @@ import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
mobile: WidgetMobilePresentation | undefined,
-) => {
- 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),
- };
-};
+) => ({
+ 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),
+});
export const shouldRenderMobileWidgetActions = ({
supportsDetails,
@@ -30,24 +23,5 @@ 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
deleted file mode 100644
index 6ad42dd38..000000000
--- a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.spec.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-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
deleted file mode 100644
index 61c657269..000000000
--- a/apps/nextjs/src/components/board/mobile/mobile-viewport-observer.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-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 312a60945..60f50c234 100644
--- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx
+++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject } from "react";
-import { useMemo, useRef, useState } from "react";
+import { useMemo, useRef } from "react";
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
@@ -20,40 +20,20 @@ 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,
- shouldKeepMobileWidgetActionsMounted,
- shouldRenderMobileWidgetActions,
-} from "./mobile-presentation";
+import { isMobileContextActionVisible, 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,
- detailsFromCard,
- detailsOpened,
- openDetails,
- closeDetails,
- widgetStateRef,
- isNearViewport,
-}: MobileWidgetActionsProps) => {
+export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
- const [isOpeningDetails, setIsOpeningDetails] = useState(false);
- const [isCompletingAction, setIsCompletingAction] = useState(false);
- const [actionTriggerHasFocus, setActionTriggerHasFocus] = useState(false);
+ const [detailsOpened, detailsDisclosure] = useDisclosure(false);
const actionTriggerRef = useRef(null);
const detailsDialogRef = useRef(null);
- const pendingContextActionRef = useRef<(() => void) | null>(null);
+ const openDetailsAfterActionsRef = useRef(false);
const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef);
const t = useI18n();
const settings = useSettings();
@@ -79,169 +59,133 @@ export const MobileWidgetActions = ({
const visibleContextActions = contextActions.filter(isMobileContextActionVisible);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const hasActions = shouldRenderMobileWidgetActions({
- supportsDetails: supportsDetails && !detailsFromCard,
+ supportsDetails,
supportsRefresh: queryKey !== undefined,
visibleContextActionCount: visibleContextActions.length,
});
- const shouldKeepMounted = shouldKeepMobileWidgetActionsMounted({
- isNearViewport,
- actionsOpened,
- detailsOpened,
- isOpeningDetails,
- isCompletingAction,
- actionTriggerHasFocus,
- });
- if ((!hasActions && !detailsOpened) || !shouldKeepMounted) return null;
+ if (!hasActions) return null;
return (
<>
- {hasActions && (
- <>
- setActionTriggerHasFocus(true)}
- onBlur={() => setActionTriggerHasFocus(false)}
- >
-
-
+
+
+
- {
- if (isOpeningDetails) {
- setIsOpeningDetails(false);
- openDetails();
- return;
- }
+ {
+ 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 && }
+
+
- 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))",
- },
- }}
- >
-
-
-
-
- )}
+ 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 7a17620ea..906abdea6 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 { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -18,7 +18,6 @@ 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) => {
@@ -105,6 +104,7 @@ 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, currentLayoutId, t, openConfirmModal, settings]);
+ }, [category, board, 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 c599f607e..8bb0a52c3 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 { useRequiredBoard } from "@homarr/boards/context";
+import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
@@ -14,6 +14,7 @@ interface Props {
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
+ const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
@@ -51,7 +52,8 @@ 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 1688f6544..e851e5c2a 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
+++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
@@ -4,8 +4,6 @@ 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";
@@ -17,12 +15,6 @@ 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 4b3c7d33f..b5e468de6 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
+++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
@@ -103,7 +103,6 @@ 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
@@ -121,7 +120,6 @@ 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
@@ -138,7 +136,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
},
- [currentLayoutId, moveAndResizeItem, moveAndResizeInnerSection],
+ [moveAndResizeItem, moveAndResizeInnerSection],
);
const onAdd = useCallback(
(addedNode: GridStackNode) => {
@@ -151,7 +149,6 @@ 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
@@ -170,7 +167,6 @@ 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
@@ -188,7 +184,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
},
- [currentLayoutId, moveItemToSection, moveInnerSectionToSection, section.id],
+ [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 3db4a7924..f61f51552 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,7 +12,6 @@ interface MoveAndResizeInnerSection {
}
interface MoveInnerSectionToSection {
innerSectionId: string;
- layoutId: string;
sectionId: string;
xOffset: number;
yOffset: number;
@@ -24,7 +23,7 @@ export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const moveAndResizeInnerSection = useCallback(
- ({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
+ ({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((section) => {
@@ -32,10 +31,12 @@ 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 !== layoutId) return layout;
+ if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
@@ -49,7 +50,7 @@ export const useSectionActions = () => {
);
const moveInnerSectionToSection = useCallback(
- ({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
+ ({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
return {
...previous,
@@ -58,10 +59,12 @@ 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 !== layoutId) return layout;
+ if (layout.layoutId !== currentLayout) 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 df8827ac5..35a8a95b6 100644
--- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts
+++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts
@@ -1,10 +1,6 @@
import { describe, expect, test } from "vitest";
-import {
- resolveIsAutomaticMobileBoard,
- resolveMobileBoardViewport,
- shouldShowMobileBoardViewportSkeleton,
-} from "./use-mobile-board";
+import { resolveMobileBoardViewport } from "./use-mobile-board";
describe("resolveMobileBoardViewport", () => {
test("keeps phones mobile regardless of their viewport width", () => {
@@ -60,26 +56,3 @@ 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 1972206f2..7197aac22 100644
--- a/apps/nextjs/src/components/board/use-mobile-board.tsx
+++ b/apps/nextjs/src/components/board/use-mobile-board.tsx
@@ -5,8 +5,6 @@ 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";
@@ -72,7 +70,6 @@ export const MobileBoardViewportProvider = ({
initialDeviceClass,
children,
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
- const { enableAutomaticMobileLayout } = useSettings();
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
getInitialValueInEffect: true,
@@ -90,42 +87,11 @@ export const MobileBoardViewportProvider = ({
return (
- {shouldShowMobileBoardViewportSkeleton({
- enableAutomaticMobileLayout,
- isResolved: viewport.isResolved,
- }) ? (
-
- ) : (
- children
- )}
+ {viewport.isResolved ? children : }
);
};
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
-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,
- });
-};
+export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
diff --git a/apps/nextjs/src/components/language/current-language-combobox.tsx b/apps/nextjs/src/components/language/current-language-combobox.tsx
index 9a8dbcb52..54d9a0f98 100644
--- a/apps/nextjs/src/components/language/current-language-combobox.tsx
+++ b/apps/nextjs/src/components/language/current-language-combobox.tsx
@@ -1,6 +1,5 @@
"use client";
-import { requestBoardEditAction } from "@homarr/boards/edit-mode";
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
import { LanguageCombobox } from "./language-combobox";
@@ -17,7 +16,7 @@ export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguage
return (
requestBoardEditAction(() => changeLocale(locale))}
+ onChange={changeLocale}
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 2201a658f..cf6d013a2 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -17,10 +17,8 @@ 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";
@@ -49,36 +47,22 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
- 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]);
+ await signOut({
+ redirect: false,
+ });
+ openModal({
+ onTimeout: () => {
+ if (logoutUrl) {
+ window.location.assign(logoutUrl);
+ return;
+ }
+ router.push("/auth/login");
+ },
+ });
+ }, [logoutUrl, openModal, router]);
return (
+ // We use keepMounted so we can add event listeners to prevent navigating away without saving the board