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
@@ -18,8 +18,6 @@ import { isNullOrWhitespace } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getQueryCacheAsync } from "@homarr/redis";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
@@ -56,12 +54,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
const resolvedParams = await params;
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const [board, session, requestHeaders, boardSettings] = await Promise.all([
|
||||
getInitialBoard(resolvedParams),
|
||||
auth(),
|
||||
headers(),
|
||||
getServerSettingByKeyAsync(db, "board"),
|
||||
]);
|
||||
const [board, session, requestHeaders] = await Promise.all([getInitialBoard(resolvedParams), auth(), headers()]);
|
||||
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
|
||||
|
||||
const itemsMap = board.items.reduce((acc, item) => {
|
||||
@@ -77,9 +70,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
const [integrations] = await Promise.all([
|
||||
getIntegrationsWithPermissionsAsync(session),
|
||||
...Array.from(itemsMap)
|
||||
.filter(([kind]) =>
|
||||
shouldPrefetchWidgetForRequest(kind, deviceType, boardSettings.enableAutomaticMobileLayout),
|
||||
)
|
||||
.filter(([kind]) => shouldPrefetchWidgetForRequest(kind, deviceType))
|
||||
.map(([kind, items]) =>
|
||||
prefetchForKindAsync(kind, queryClient, items).catch((error) => {
|
||||
logger.error(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
@@ -29,8 +29,7 @@ import {
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { BoardEditAction, BoardEditActionEvent } from "@homarr/boards/edit-mode";
|
||||
import { boardEditActionEventName, requestBoardEditAction, useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
@@ -62,8 +61,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
const isMobile = useIsMobileBoard();
|
||||
const t = useI18n();
|
||||
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileMoreMenu showSettings={hasChangeAccess && !demoReadOnly} />;
|
||||
}
|
||||
@@ -218,6 +215,8 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges, hidden]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
@@ -270,7 +269,6 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
|
||||
const tProfile = useScopedI18n("common.userAvatar.menu");
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [opened, disclosure] = useDisclosure(false);
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
const moreTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pendingSectionAnchorId = useRef<string | null>(null);
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
@@ -292,26 +290,6 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
|
||||
moreTriggerRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
setIsSigningOut(true);
|
||||
|
||||
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");
|
||||
}
|
||||
window.location.replace(logoutUrl ?? "/auth/login");
|
||||
return true;
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
showErrorNotification({
|
||||
title: tProfile("logoutError.title"),
|
||||
message: tProfile("logoutError.message"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [logoutUrl, tProfile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderButton ref={moreTriggerRef} onClick={disclosure.open} aria-label={t("board.mobile.more")}>
|
||||
@@ -413,8 +391,13 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
|
||||
size="lg"
|
||||
justify="flex-start"
|
||||
leftSection={<IconLogout size={20} />}
|
||||
loading={isSigningOut}
|
||||
onClick={() => requestBoardEditAction(handleSignOut)}
|
||||
onClick={() => {
|
||||
void signOut({ redirect: false })
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
window.location.assign(logoutUrl ?? "/auth/login");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tProfile("logout")}
|
||||
</Button>
|
||||
@@ -449,207 +432,62 @@ const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank']):not([download])";
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const router = useRouter();
|
||||
const dependenciesRef = useRef({ openConfirmModal, router, t });
|
||||
|
||||
useEffect(() => {
|
||||
dependenciesRef.current = { openConfirmModal, router, t };
|
||||
}, [openConfirmModal, router, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
|
||||
const guardStateKey = "__homarrBoardEditGuard";
|
||||
const guardId = `${Date.now()}-${Math.random()}`;
|
||||
let guardedUrl = window.location.href;
|
||||
let isLeaving = false;
|
||||
let isConfirming = false;
|
||||
let isActionPending = false;
|
||||
let shouldRemoveGuardEntry = true;
|
||||
let didPageHide = false;
|
||||
let departureFallbackTimer: number | undefined;
|
||||
const handleClick = (event: Event) => {
|
||||
const target = (event.target as HTMLElement).closest("a");
|
||||
|
||||
const pushGuardEntry = () => {
|
||||
window.history.pushState(
|
||||
{
|
||||
...(window.history.state as Record<string, unknown> | null),
|
||||
[guardStateKey]: guardId,
|
||||
},
|
||||
document.title,
|
||||
guardedUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const rearmGuard = () => {
|
||||
window.clearTimeout(departureFallbackTimer);
|
||||
departureFallbackTimer = undefined;
|
||||
isLeaving = false;
|
||||
shouldRemoveGuardEntry = true;
|
||||
|
||||
if (
|
||||
window.location.href === guardedUrl &&
|
||||
(window.history.state as Record<string, unknown> | null)?.[guardStateKey] !== guardId
|
||||
) {
|
||||
pushGuardEntry();
|
||||
if (!target) {
|
||||
console.warn("No anchor element found for click event", event);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDepartureFallback = () => {
|
||||
window.clearTimeout(departureFallbackTimer);
|
||||
departureFallbackTimer = window.setTimeout(() => {
|
||||
departureFallbackTimer = undefined;
|
||||
if (!didPageHide) {
|
||||
guardedUrl = window.location.href;
|
||||
rearmGuard();
|
||||
}
|
||||
}, 1_000);
|
||||
};
|
||||
event.preventDefault();
|
||||
|
||||
const openLeaveConfirmation = (action: BoardEditAction) => {
|
||||
if (isConfirming) return;
|
||||
isConfirming = true;
|
||||
|
||||
const dependencies = dependenciesRef.current;
|
||||
dependencies.openConfirmModal({
|
||||
title: dependencies.t("board.action.edit.confirmLeave.title"),
|
||||
children: dependencies.t("board.action.edit.confirmLeave.message"),
|
||||
async onConfirm() {
|
||||
isActionPending = true;
|
||||
isLeaving = true;
|
||||
shouldRemoveGuardEntry = false;
|
||||
didPageHide = false;
|
||||
|
||||
try {
|
||||
const result = await action();
|
||||
if (result === false) {
|
||||
rearmGuard();
|
||||
} else {
|
||||
scheduleDepartureFallback();
|
||||
}
|
||||
} catch {
|
||||
rearmGuard();
|
||||
} finally {
|
||||
isActionPending = false;
|
||||
isConfirming = false;
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
if (!isActionPending) {
|
||||
isConfirming = false;
|
||||
}
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
onConfirm() {
|
||||
router.push(target.href);
|
||||
},
|
||||
confirmProps: {
|
||||
children: dependencies.t("common.action.discard"),
|
||||
children: t("common.action.discard"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
pushGuardEntry();
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
!(event.target instanceof Element)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target.closest<HTMLAnchorElement>(anchorSelector);
|
||||
if (!target) return;
|
||||
|
||||
const handlePopState = (event: Event) => {
|
||||
window.history.pushState(null, document.title, window.location.href);
|
||||
event.preventDefault();
|
||||
const destination = new URL(target.href, window.location.href);
|
||||
|
||||
openLeaveConfirmation(() => {
|
||||
if (destination.origin === window.location.origin) {
|
||||
dependenciesRef.current.router.replace(`${destination.pathname}${destination.search}${destination.hash}`);
|
||||
} else {
|
||||
window.location.replace(destination.href);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
if (isActionPending) {
|
||||
if (
|
||||
window.location.href === guardedUrl &&
|
||||
(window.history.state as Record<string, unknown> | null)?.[guardStateKey] !== guardId
|
||||
) {
|
||||
pushGuardEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isLeaving) return;
|
||||
|
||||
pushGuardEntry();
|
||||
openLeaveConfirmation(() => {
|
||||
window.history.go(-2);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBoardEditAction = (event: Event) => {
|
||||
event.preventDefault();
|
||||
openLeaveConfirmation((event as BoardEditActionEvent).detail.action);
|
||||
};
|
||||
|
||||
const handlePageShow = (event: PageTransitionEvent) => {
|
||||
if (!event.persisted) return;
|
||||
|
||||
didPageHide = false;
|
||||
guardedUrl = window.location.href;
|
||||
isLeaving = false;
|
||||
isConfirming = false;
|
||||
shouldRemoveGuardEntry = true;
|
||||
if ((window.history.state as Record<string, unknown> | null)?.[guardStateKey] !== guardId) {
|
||||
pushGuardEntry();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageHide = () => {
|
||||
didPageHide = true;
|
||||
};
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (isLeaving || env.NODE_ENV === "development") return;
|
||||
if (env.NODE_ENV === "development") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
document.addEventListener(boardEditActionEventName, handleBoardEditAction);
|
||||
const anchors = document.querySelectorAll(anchorSelector);
|
||||
anchors.forEach((link) => {
|
||||
link.addEventListener("click", handleClick);
|
||||
});
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
window.addEventListener("pageshow", handlePageShow);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
document.removeEventListener(boardEditActionEventName, handleBoardEditAction);
|
||||
anchors.forEach((link) => {
|
||||
link.removeEventListener("click", handleClick);
|
||||
});
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
window.removeEventListener("pageshow", handlePageShow);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.clearTimeout(departureFallbackTimer);
|
||||
|
||||
if (
|
||||
shouldRemoveGuardEntry &&
|
||||
window.location.href === guardedUrl &&
|
||||
(window.history.state as Record<string, unknown> | null)?.[guardStateKey] === guardId
|
||||
) {
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
}, [isDirty]);
|
||||
};
|
||||
@@ -4,18 +4,14 @@ import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy";
|
||||
|
||||
describe("shouldPrefetchWidgetForRequest", () => {
|
||||
test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", deviceType, true)).toBe(false);
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps downloads prefetched for desktop requests", () => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", undefined, true)).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps downloads prefetched for legacy responsive mobile boards", () => {
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", "mobile", false)).toBe(true);
|
||||
expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => {
|
||||
expect(shouldPrefetchWidgetForRequest(kind, "mobile", true)).toBe(true);
|
||||
expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export const shouldPrefetchWidgetForRequest = (
|
||||
kind: WidgetKind,
|
||||
deviceType: string | undefined,
|
||||
enableAutomaticMobileLayout: boolean,
|
||||
) => {
|
||||
export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => {
|
||||
const isMobileDevice = deviceType === "mobile" || deviceType === "tablet";
|
||||
|
||||
return kind !== "downloads" || !isMobileDevice || !enableAutomaticMobileLayout;
|
||||
return kind !== "downloads" || !isMobileDevice;
|
||||
};
|
||||
@@ -1,42 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Button, Fieldset, Group, Input, Slider, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getDesktopLayout } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardSaveLayoutSchema, boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||
import { boardSaveLayoutSchema } from "@homarr/validation/board";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
enableAutomaticMobileLayout: boolean;
|
||||
}
|
||||
|
||||
const getNextAvailableBreakpoint = (layouts: Array<{ breakpoint: number }>) => {
|
||||
const breakpoints = new Set(layouts.map((layout) => layout.breakpoint));
|
||||
const commonBreakpoints = [0, 480, 768, 1024, 1200, 1440, 1920];
|
||||
const commonBreakpoint = commonBreakpoints.find((breakpoint) => !breakpoints.has(breakpoint));
|
||||
if (commonBreakpoint !== undefined) return commonBreakpoint;
|
||||
|
||||
for (let breakpoint = 0; breakpoint <= 32767; breakpoint++) {
|
||||
if (!breakpoints.has(breakpoint)) return breakpoint;
|
||||
}
|
||||
|
||||
throw new Error("No responsive layout breakpoints are available");
|
||||
};
|
||||
|
||||
export const LayoutSettingsContent = ({ board, enableAutomaticMobileLayout }: Props) =>
|
||||
enableAutomaticMobileLayout ? (
|
||||
<AutomaticLayoutSettingsContent board={board} />
|
||||
) : (
|
||||
<ResponsiveLayoutSettingsContent board={board} />
|
||||
);
|
||||
|
||||
const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
@@ -68,20 +45,13 @@ const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
{t("board.setting.section.layout.desktop.description")}
|
||||
</Text>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
{...form.getInputProps("columnCount")}
|
||||
/>
|
||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
|
||||
</Input.Wrapper>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("board.setting.section.layout.mobile.automaticDescription")}
|
||||
{t("board.setting.section.layout.mobile.description")}
|
||||
</Text>
|
||||
|
||||
<Group justify="end">
|
||||
@@ -93,124 +63,3 @@ const AutomaticLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponsiveLayoutSettingsContent = ({ board }: Pick<Props, "board">) => {
|
||||
const t = useI18n();
|
||||
const utils = clientApi.useUtils();
|
||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||
initialValues: {
|
||||
layouts: board.layouts,
|
||||
},
|
||||
});
|
||||
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
|
||||
onSuccess(layouts) {
|
||||
const values = { layouts };
|
||||
form.setValues(values);
|
||||
form.resetDirty(values);
|
||||
},
|
||||
onSettled() {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void utils.board.getHomeBoard.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
saveLayouts({
|
||||
id: board.id,
|
||||
...values,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("board.setting.section.layout.responsive.description")}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues({
|
||||
layouts: [
|
||||
...form.values.layouts,
|
||||
{
|
||||
id: createId(),
|
||||
name: t("board.setting.section.layout.responsive.defaultName"),
|
||||
columnCount: 10,
|
||||
breakpoint: getNextAvailableBreakpoint(form.values.layouts),
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("board.setting.section.layout.responsive.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{form.values.layouts.map((layout, index) => (
|
||||
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
|
||||
<Grid>
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<Input.Wrapper label={t("layout.field.columnCount.label")}>
|
||||
<Slider
|
||||
mt="xs"
|
||||
min={1}
|
||||
max={24}
|
||||
step={1}
|
||||
thumbLabel={t("layout.field.columnCount.label")}
|
||||
{...form.getInputProps(`layouts.${index}.columnCount`)}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||
label={t("layout.field.breakpoint.label")}
|
||||
description={t("layout.field.breakpoint.description")}
|
||||
min={0}
|
||||
max={32767}
|
||||
allowDecimal={false}
|
||||
allowNegative={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{form.values.layouts.length >= 2 && (
|
||||
<Group justify="end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setValues((previous) => {
|
||||
const previousLayouts = previous.layouts ?? [];
|
||||
return previousLayouts.length >= 2
|
||||
? {
|
||||
layouts: previousLayouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||
}
|
||||
: previous;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Fieldset>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -90,10 +90,7 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<GeneralSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="layout" icon={IconLayout}>
|
||||
<LayoutSettingsContent
|
||||
board={board}
|
||||
enableAutomaticMobileLayout={boardSettings.enableAutomaticMobileLayout}
|
||||
/>
|
||||
<LayoutSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="background" icon={IconPhoto}>
|
||||
<BackgroundSettingsContent board={board} />
|
||||
|
||||
@@ -48,22 +48,13 @@ export const BoardSwitcherLogo = () => {
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
{boards.map((availableBoard) => {
|
||||
const isCurrent = availableBoard.id === board.id;
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<Menu.Item key={availableBoard.id} leftSection={<IconCheck size={20} />} aria-current="page" disabled>
|
||||
<Text truncate title={availableBoard.name}>
|
||||
{availableBoard.name}
|
||||
</Text>
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={availableBoard.id}
|
||||
component={Link}
|
||||
href={`/boards/${availableBoard.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
<Text truncate title={availableBoard.name}>
|
||||
{availableBoard.name}
|
||||
|
||||
@@ -107,7 +107,6 @@ export default async function Layout(props: {
|
||||
board: {
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
enableAutomaticMobileLayout: serverSettings.board.enableAutomaticMobileLayout,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
|
||||
@@ -33,13 +33,6 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("mobileLayout.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableAutomaticMobileLayout", { type: "checkbox" })}
|
||||
label={tBoard("mobileLayout.enable.label")}
|
||||
description={tBoard("mobileLayout.enable.description")}
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
<Switch
|
||||
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
@@ -18,7 +17,6 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||
defaultValues: ServerSettings[TKey];
|
||||
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
@@ -26,7 +24,6 @@ export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
|
||||
showSuccessNotification({
|
||||
message: tSettings("notification.success.message"),
|
||||
});
|
||||
router.refresh();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
|
||||
@@ -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