Revert "feat: complete automatic mobile board UX"
This reverts commit 7959c20d88.
This commit is contained in:
1 parent
dba9a250ff
commit
b612fb9a89
85 files changed
+565
-2867
No files matched your search
@@ -60,15 +60,9 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
return layouts.map((layoutId) => {
|
||||
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
|
||||
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
|
||||
const layoutItemSize = boardLayout
|
||||
? {
|
||||
...itemSize,
|
||||
width: Math.min(itemSize.width, boardLayout.columnCount),
|
||||
}
|
||||
: itemSize;
|
||||
|
||||
const emptyPosition = boardLayout
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, layoutItemSize)
|
||||
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
|
||||
: { xOffset: 0, yOffset: 0 };
|
||||
|
||||
if (!emptyPosition) {
|
||||
@@ -76,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
|
||||
}
|
||||
|
||||
return {
|
||||
width: layoutItemSize.width,
|
||||
height: layoutItemSize.height,
|
||||
width: itemSize.width,
|
||||
height: itemSize.height,
|
||||
...emptyPosition,
|
||||
sectionId: currentSection.id,
|
||||
layoutId,
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveAndResizeItemInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -10,8 +11,10 @@ export interface MoveAndResizeItemInput {
|
||||
}
|
||||
|
||||
export const moveAndResizeItemCallback =
|
||||
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
@@ -20,7 +23,7 @@ export const moveAndResizeItemCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== layoutId
|
||||
layout.layoutId !== currentLayout
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveItemToSectionInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -11,8 +12,10 @@ export interface MoveItemToSectionInput {
|
||||
}
|
||||
|
||||
export const moveItemToSectionCallback =
|
||||
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(board);
|
||||
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) =>
|
||||
@@ -21,7 +24,7 @@ export const moveItemToSectionCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== layoutId
|
||||
layout.layoutId !== currentLayout
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
@@ -10,10 +10,6 @@ import { ItemMockBuilder } from "./mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||
|
||||
describe("item actions create-item", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("should add it to first section", () => {
|
||||
// Arrange
|
||||
const itemKind = "clock";
|
||||
@@ -112,24 +108,4 @@ describe("item actions create-item", () => {
|
||||
{ height: 1, width: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("clamps wide widgets to each retained layout", () => {
|
||||
const desktopLayout = new LayoutMockBuilder({ id: "desktop", columnCount: 12 }).build();
|
||||
const mobileLayout = new LayoutMockBuilder({ id: "mobile", columnCount: 1 }).build();
|
||||
const board = new BoardMockBuilder()
|
||||
.addLayout(desktopLayout)
|
||||
.addLayout(mobileLayout)
|
||||
.addEmptySection({ id: "root", yOffset: 0 })
|
||||
.build();
|
||||
|
||||
const result = createItemCallback({ kind: "mediaMissing" })(board);
|
||||
const item = result.items.at(0);
|
||||
|
||||
expect(item?.layouts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ layoutId: desktopLayout.id, width: 4 }),
|
||||
expect.objectContaining({ layoutId: mobileLayout.id, width: 1 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -106,10 +106,6 @@ describe("get first empty position", () => {
|
||||
|
||||
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||
});
|
||||
|
||||
test("does not place an item wider than the layout", () => {
|
||||
expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -21,7 +25,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -39,6 +43,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -50,7 +56,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -22,7 +26,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -40,6 +44,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -52,7 +58,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card, Center, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
@@ -10,8 +10,6 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetDisplayMode } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
@@ -29,9 +27,7 @@ interface BoardItemContentProps {
|
||||
displayMode?: WidgetDisplayMode;
|
||||
disableContextMenu?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
disableItemConfiguration?: boolean;
|
||||
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
|
||||
onOpenDetails?: () => void;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
@@ -45,9 +41,7 @@ export const BoardItemContent = ({
|
||||
displayMode = "default",
|
||||
disableContextMenu = false,
|
||||
isReadOnly = false,
|
||||
disableItemConfiguration = false,
|
||||
widgetStateRef: externalWidgetStateRef,
|
||||
onOpenDetails,
|
||||
}: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
@@ -79,9 +73,7 @@ export const BoardItemContent = ({
|
||||
height={height}
|
||||
displayMode={displayMode}
|
||||
isReadOnly={isReadOnly}
|
||||
disableItemConfiguration={disableItemConfiguration}
|
||||
widgetStateRef={widgetStateRef}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
@@ -125,23 +117,13 @@ interface InnerContentProps {
|
||||
height: number;
|
||||
displayMode: WidgetDisplayMode;
|
||||
isReadOnly: boolean;
|
||||
disableItemConfiguration: boolean;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
onOpenDetails?: () => void;
|
||||
}
|
||||
|
||||
const InnerContent = ({
|
||||
item,
|
||||
displayMode,
|
||||
isReadOnly,
|
||||
disableItemConfiguration,
|
||||
onOpenDetails,
|
||||
...dimensions
|
||||
}: InnerContentProps) => {
|
||||
const InnerContent = ({ item, displayMode, isReadOnly, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const t = useI18n();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
@@ -151,14 +133,6 @@ const InnerContent = ({
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
const hasNativeMobileSummary =
|
||||
"mobile" in definition &&
|
||||
definition.mobile !== undefined &&
|
||||
"supportsCompactSummary" in definition.mobile &&
|
||||
definition.mobile.supportsCompactSummary === true;
|
||||
const useGenericMobileSummary = displayMode === "mobileSummary" && !hasNativeMobileSummary;
|
||||
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
||||
const showItemMenu = !isReadOnly && !disableItemConfiguration;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
@@ -171,7 +145,7 @@ const InnerContent = ({
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
{showItemMenu && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
@@ -184,79 +158,32 @@ const InnerContent = ({
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
{showItemMenu && <BoardItemMenu offset={4} item={newItem} />}
|
||||
{useGenericMobileSummary ? (
|
||||
<GenericMobileSummary
|
||||
icon={definition.icon}
|
||||
title={title}
|
||||
widgetName={t(`widget.${item.kind}.name`)}
|
||||
detailsLabel={t("board.mobile.details")}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
) : (
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
isReadOnly={isReadOnly}
|
||||
setOptions={(partialNewOptions) => {
|
||||
if (isReadOnly) return;
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
displayMode={displayMode}
|
||||
{...dimensions}
|
||||
/>
|
||||
)}
|
||||
{!isReadOnly && <BoardItemMenu offset={4} item={newItem} />}
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
isReadOnly={isReadOnly}
|
||||
setOptions={(partialNewOptions) => {
|
||||
if (isReadOnly) return;
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
displayMode={displayMode}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const GenericMobileSummary = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
widgetName,
|
||||
detailsLabel,
|
||||
onOpenDetails,
|
||||
}: {
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
widgetName: string;
|
||||
detailsLabel: string;
|
||||
onOpenDetails?: () => void;
|
||||
}) => (
|
||||
<UnstyledButton
|
||||
h="100%"
|
||||
w="100%"
|
||||
p="sm"
|
||||
onClick={onOpenDetails}
|
||||
aria-label={`${detailsLabel}: ${title}`}
|
||||
data-mobile-generic-summary
|
||||
>
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap={4} maw="100%">
|
||||
<ThemeIcon variant="light" size={36} radius="xl">
|
||||
<Icon size={20} aria-hidden />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm" ta="center" lineClamp={1} w="100%">
|
||||
{widgetName}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" ta="center" lineClamp={1} w="100%">
|
||||
{detailsLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
return null;
|
||||
|
||||
@@ -5,56 +5,66 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Skeleton } from "@mantine/core";
|
||||
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { observeMobileViewportProximity } from "./mobile-viewport-observer";
|
||||
|
||||
interface DeferredMobileItemProps extends PropsWithChildren {
|
||||
eager: boolean;
|
||||
unmountWhenOffscreen: boolean;
|
||||
onNearViewportChange?: (isNearViewport: boolean) => void;
|
||||
}
|
||||
|
||||
export { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./mobile-viewport-observer";
|
||||
export const mobilePreloadViewportMultiplier = 2;
|
||||
|
||||
export const DeferredMobileItem = ({
|
||||
eager,
|
||||
unmountWhenOffscreen,
|
||||
onNearViewportChange,
|
||||
children,
|
||||
}: DeferredMobileItemProps) => {
|
||||
export const getMobilePreloadRootMargin = (viewportHeight: number) =>
|
||||
`${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`;
|
||||
|
||||
export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, 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);
|
||||
onNearViewportChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = wrapperRef.current;
|
||||
if (!element) {
|
||||
if (!element || typeof IntersectionObserver === "undefined") {
|
||||
setHasMounted(true);
|
||||
onNearViewportChange?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
return observeMobileViewportProximity(
|
||||
element,
|
||||
(isNearViewport) => {
|
||||
onNearViewportChange?.(isNearViewport);
|
||||
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) {
|
||||
setHasMounted(true);
|
||||
return;
|
||||
}
|
||||
if (isNearViewport) {
|
||||
hasMountedOnceRef.current = true;
|
||||
setHasMounted(true);
|
||||
if (!unmountWhenOffscreen) observer?.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (unmountWhenOffscreen && !element.contains(document.activeElement)) {
|
||||
setHasMounted(false);
|
||||
}
|
||||
},
|
||||
{ once: !unmountWhenOffscreen },
|
||||
);
|
||||
}, [eager, onNearViewportChange, unmountWhenOffscreen]);
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
|
||||
|
||||
@@ -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<Record<string, unknown> | null>(null);
|
||||
const presentation = getMobilePresentation(item);
|
||||
const [isNearViewport, setIsNearViewport] = useState(presentation.eager);
|
||||
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -74,30 +71,16 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => {
|
||||
data-mobile-board-item={item.id}
|
||||
data-mobile-display-mode={presentation.displayMode}
|
||||
>
|
||||
<DeferredMobileItem
|
||||
eager={presentation.eager}
|
||||
unmountWhenOffscreen={presentation.unmountWhenOffscreen}
|
||||
onNearViewportChange={setIsNearViewport}
|
||||
>
|
||||
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode={presentation.displayMode}
|
||||
disableContextMenu
|
||||
isReadOnly
|
||||
widgetStateRef={widgetStateRef}
|
||||
onOpenDetails={presentation.usesGenericSummary ? detailsDisclosure.open : undefined}
|
||||
/>
|
||||
</DeferredMobileItem>
|
||||
<MobileWidgetActions
|
||||
item={item}
|
||||
supportsDetails={presentation.supportsDetails}
|
||||
detailsFromCard={presentation.usesGenericSummary}
|
||||
detailsOpened={detailsOpened}
|
||||
openDetails={detailsDisclosure.open}
|
||||
closeDetails={detailsDisclosure.close}
|
||||
widgetStateRef={widgetStateRef}
|
||||
isNearViewport={isNearViewport}
|
||||
/>
|
||||
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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<Element>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Element, Set<MobileViewportSubscription>>();
|
||||
|
||||
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);
|
||||
};
|
||||
};
|
||||
@@ -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<Record<string, unknown> | 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<HTMLButtonElement | null>(null);
|
||||
const detailsDialogRef = useRef<HTMLDivElement | null>(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 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
ref={actionTriggerRef}
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions", { title })}
|
||||
onClick={actionsDisclosure.open}
|
||||
onFocus={() => setActionTriggerHasFocus(true)}
|
||||
onBlur={() => setActionTriggerHasFocus(false)}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
ref={actionTriggerRef}
|
||||
className={classes.widgetAction}
|
||||
variant="filled"
|
||||
color="dark"
|
||||
radius="xl"
|
||||
size={44}
|
||||
aria-label={t("board.mobile.actions", { title })}
|
||||
onClick={actionsDisclosure.open}
|
||||
>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
<Drawer
|
||||
opened={actionsOpened}
|
||||
onClose={actionsDisclosure.close}
|
||||
title={title}
|
||||
position="bottom"
|
||||
size="auto"
|
||||
padding="md"
|
||||
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
|
||||
returnFocus={false}
|
||||
styles={{
|
||||
content: {
|
||||
paddingLeft: "env(safe-area-inset-left)",
|
||||
paddingRight: "env(safe-area-inset-right)",
|
||||
},
|
||||
}}
|
||||
onExitTransitionEnd={() => {
|
||||
if (isOpeningDetails) {
|
||||
setIsOpeningDetails(false);
|
||||
openDetails();
|
||||
return;
|
||||
}
|
||||
<Drawer
|
||||
opened={actionsOpened}
|
||||
onClose={actionsDisclosure.close}
|
||||
title={title}
|
||||
position="bottom"
|
||||
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 && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
openDetailsAfterActionsRef.current = true;
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
</Button>
|
||||
)}
|
||||
{visibleContextActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="subtle"
|
||||
color={action.color}
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={action.disabled}
|
||||
leftSection={Icon ? <Icon size={20} /> : undefined}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
|
||||
{supportsDetails && !detailsFromCard && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
leftSection={<IconMaximize size={20} />}
|
||||
onClick={() => {
|
||||
setIsOpeningDetails(true);
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{t("board.mobile.details")}
|
||||
</Button>
|
||||
)}
|
||||
{visibleContextActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.key}
|
||||
variant="subtle"
|
||||
color={action.color}
|
||||
justify="flex-start"
|
||||
size="lg"
|
||||
disabled={action.disabled}
|
||||
leftSection={Icon ? <Icon size={20} /> : undefined}
|
||||
onClick={() => {
|
||||
pendingContextActionRef.current = action.onClick;
|
||||
setIsCompletingAction(true);
|
||||
actionsDisclosure.close();
|
||||
}}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
|
||||
</Stack>
|
||||
</Drawer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{supportsDetails && (
|
||||
<Modal
|
||||
ref={detailsDialogRef}
|
||||
opened={detailsOpened}
|
||||
onClose={closeDetails}
|
||||
title={title}
|
||||
fullScreen
|
||||
padding="md"
|
||||
returnFocus={detailsFromCard}
|
||||
trapFocus={!hasNestedDialog}
|
||||
closeOnEscape={!hasNestedDialog}
|
||||
aria-hidden={hasNestedDialog || undefined}
|
||||
inert={hasNestedDialog || undefined}
|
||||
onExitTransitionEnd={() => {
|
||||
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))",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box className={classes.detailContent}>
|
||||
<BoardItemContent
|
||||
item={item}
|
||||
displayMode="mobileDetail"
|
||||
disableContextMenu
|
||||
disableItemConfiguration
|
||||
widgetStateRef={widgetStateRef}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
<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,
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</Badge>
|
||||
)}
|
||||
<GridStack section={section} className="min-row" />
|
||||
{/* Use unique key by layout to reinitialize gridstack */}
|
||||
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
|
||||
@@ -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 <GridStackForLayout key={currentLayoutId} section={section} {...props} />;
|
||||
};
|
||||
|
||||
const GridStackForLayout = ({ section, ...props }: Props) => {
|
||||
const { items, innerSections } = useSectionItems(section.id);
|
||||
const itemIds = [...items, ...innerSections].map((item) => item.id);
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export const useGridstack = (section: Omit<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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<Section, "items">, 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<MobileBoardViewportContext.Provider value={viewport}>
|
||||
{shouldShowMobileBoardViewportSkeleton({
|
||||
enableAutomaticMobileLayout,
|
||||
isResolved: viewport.isResolved,
|
||||
}) ? (
|
||||
<MobileBoardViewportSkeleton />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
|
||||
</MobileBoardViewportContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 (
|
||||
<LanguageCombobox
|
||||
value={currentLocale}
|
||||
onChange={(locale) => requestBoardEditAction(() => changeLocale(locale))}
|
||||
onChange={changeLocale}
|
||||
isPending={isPending}
|
||||
width={width}
|
||||
withinPortal={withinPortal}
|
||||
|
||||
@@ -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<boolean>((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
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
@@ -116,11 +100,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item
|
||||
onClick={() => requestBoardEditAction(handleSignout)}
|
||||
leftSection={<IconLogout size="1rem" />}
|
||||
color="red"
|
||||
>
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user