Revert "feat: improve mobile board experience"

This reverts commit 7c0e98c55b.
This commit is contained in:
ajnart committed 2026-07-29 20:07:37 +02:00
1 parent c1819d6fdc
commit e2732dce2c
45 files changed
+129 -1436

No files matched your search

@@ -44,7 +44,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
mobileProfileInActions: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
@@ -1,10 +1,10 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect } 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";
import { useDisclosure, useHotkeys, useReducedMotion } from "@mantine/hooks";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
IconBox,
IconBoxAlignTop,
@@ -17,18 +17,11 @@ import {
IconReplace,
IconResize,
IconSettings,
IconDotsVertical,
IconHome,
IconLogin,
IconList,
IconLogout,
IconTool,
IconUser,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { signOut, useSession } from "@homarr/auth/client";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
@@ -39,18 +32,14 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { createMobileBoardElements } from "~/components/board/mobile/mobile-layout";
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
@@ -58,11 +47,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const isMobile = useIsMobileBoard();
const t = useI18n();
if (isMobile) {
return <MobileMoreMenu showSettings={hasChangeAccess && !demoReadOnly} />;
}
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
@@ -76,7 +60,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
{!demoReadOnly && (
<OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("item.menu.label.settings")}>
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</OnboardingTour.Target>
@@ -141,7 +125,7 @@ const AddMenu = () => {
return (
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("common.action.add")}>
<HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
@@ -180,7 +164,6 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
const tItem = useScopedI18n("item");
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
onSuccess() {
showSuccessNotification({
@@ -220,7 +203,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
return (
<OnboardingTour.Target id="board-edit-mode">
<HeaderButton onClick={toggle} loading={isPending} aria-label={tItem("action.edit")}>
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
</HeaderButton>
</OnboardingTour.Target>
@@ -229,14 +212,13 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const t = useI18n();
return (
<OnboardingTour.Target id="board-switcher">
<Box>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.mobile.currentBoard")}>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
@@ -260,145 +242,6 @@ const SelectBoardsMenu = () => {
);
};
const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
const board = useRequiredBoard();
const { data: session } = useSession();
const { logoutUrl } = useAuthContext();
const t = useI18n();
const tProfile = useScopedI18n("common.userAvatar.menu");
const reduceMotion = useReducedMotion();
const [opened, disclosure] = useDisclosure(false);
const desktopLayout = getDesktopLayout(board);
const sections = useMemo(
() => createMobileBoardElements(board, desktopLayout.id).filter((element) => element.type === "sectionHeading"),
[board, desktopLayout.id],
);
const jumpToSection = (anchorId: string) => {
disclosure.close();
requestAnimationFrame(() => {
const heading = document.getElementById(anchorId);
heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
heading?.focus({ preventScroll: true });
});
};
return (
<>
<HeaderButton onClick={disclosure.open} aria-label={t("board.mobile.more")}>
<IconDotsVertical stroke={1.5} />
</HeaderButton>
<Drawer
opened={opened}
onClose={disclosure.close}
title={t("board.mobile.more")}
position="bottom"
size="auto"
padding="md"
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
<Button
component={Link}
href="/boards"
variant="subtle"
size="lg"
justify="flex-start"
leftSection={<IconHome size={20} />}
>
{tProfile("homeBoard")}
</Button>
<CurrentColorSchemeCombobox w="100%" />
<CurrentLanguageCombobox width="100%" withinPortal={false} />
{session ? (
<>
<Button
component={Link}
href={`/manage/users/${session.user.id}/general`}
variant="subtle"
size="lg"
justify="flex-start"
leftSection={<IconUser size={20} />}
>
{tProfile("preferences")}
</Button>
<Button
component={Link}
href="/manage"
variant="subtle"
size="lg"
justify="flex-start"
leftSection={<IconTool size={20} />}
>
{tProfile("management")}
</Button>
<Button
variant="subtle"
color="red"
size="lg"
justify="flex-start"
leftSection={<IconLogout size={20} />}
onClick={() => {
void signOut({ redirect: false }).then(() => {
window.location.assign(logoutUrl ?? "/auth/login");
});
}}
>
{tProfile("logout")}
</Button>
</>
) : (
<Button
component={Link}
href="/auth/login"
variant="subtle"
size="lg"
justify="flex-start"
leftSection={<IconLogin size={20} />}
>
{tProfile("login")}
</Button>
)}
{showSettings && (
<Button
component={Link}
href={`/boards/${board.name}/settings`}
variant="subtle"
size="lg"
justify="flex-start"
leftSection={<IconSettings size={20} />}
>
{t("item.menu.label.settings")}
</Button>
)}
{sections.length >= 2 && (
<>
{showSettings && <Divider />}
<Group gap="xs" px="sm" pt="xs">
<IconList size={18} aria-hidden />
<Text size="sm" fw={600}>
{t("board.mobile.sections")}
</Text>
</Group>
{sections.map((section) => (
<Button
key={section.id}
variant="subtle"
size="lg"
justify="flex-start"
onClick={() => jumpToSection(section.anchorId)}
>
{section.title}
</Button>
))}
</>
)}
</Stack>
</Drawer>
</>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
@@ -1,57 +0,0 @@
"use client";
import { Group, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import { IconCheck, IconChevronDown, IconLayoutBoard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { BoardLogo } from "~/components/layout/logo/board-logo";
export const BoardSwitcherLogo = () => {
const board = useRequiredBoard();
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
const t = useI18n();
return (
<Menu position="bottom-start" width={280}>
<Menu.Target>
<UnstyledButton
aria-label={`${t("board.mobile.currentBoard")}: ${board.name}`}
maw="min(42vw, 20rem)"
mih={44}
px={4}
>
<Group gap="xs" wrap="nowrap">
<BoardLogo size={28} />
<Text fw={600} truncate>
{board.name}
</Text>
<IconChevronDown size={16} aria-hidden />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("board.mobile.currentBoard")}</Menu.Label>
<ScrollArea.Autosize mah={320}>
{boards.map((availableBoard) => {
const isCurrent = availableBoard.id === board.id;
return (
<Menu.Item
key={availableBoard.id}
component={Link}
href={`/boards/${availableBoard.name}`}
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
aria-current={isCurrent ? "page" : undefined}
>
{availableBoard.name}
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
};
@@ -3,16 +3,14 @@
import { IconLayoutBoard } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { HeaderButton } from "~/components/layout/header/button";
export const BoardOtherHeaderActions = () => {
const board = useRequiredBoard();
const t = useI18n();
return (
<HeaderButton href={`/boards/${board.name}`} aria-label={t("common.userAvatar.menu.homeBoard")}>
<HeaderButton href={`/boards/${board.name}`}>
<IconLayoutBoard stroke={1.5} />
</HeaderButton>
);
@@ -10,9 +10,9 @@ import { EditModeProvider } from "@homarr/boards/edit-mode";
import { userAgent } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { MobileBoardDeviceClass } from "~/components/board/use-mobile-board";
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -21,7 +21,6 @@ import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme";
import { BoardSwitcherLogo } from "./_board-switcher-logo";
const logger = createLogger({ module: "createBoardLayout" });
@@ -34,14 +33,12 @@ interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
mobileProfileInActions?: boolean;
}
export const createBoardLayout = <TParams extends Params>({
headerActions,
getInitialBoardAsync: getInitialBoard,
withTour = false,
mobileProfileInActions = false,
}: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({
params,
@@ -68,36 +65,23 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
});
const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]);
const deviceType = userAgent(new Headers(requestHeaders)).device.type;
const initialDeviceClass: MobileBoardDeviceClass =
deviceType === "mobile" ? "phone" : deviceType === "tablet" ? "tablet" : "desktop";
const initialIsMobile = userAgent(new Headers(requestHeaders)).device.type === "mobile";
return (
<MobileBoardViewportProvider initialDeviceClass={initialDeviceClass}>
<MobileBoardViewportProvider initialIsMobile={initialIsMobile}>
<BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourWrapper hasSession={withTour && !!session}>
<ClientShell hasNavigation={false} withSafeArea>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardSwitcherLogo />}
logoHref={null}
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
withSafeArea
hideUserOnMobileBoard={mobileProfileInActions}
/>
<AppShellMain
style={{
paddingRight: "calc(var(--mantine-spacing-md) + env(safe-area-inset-right))",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
paddingLeft: "calc(var(--mantine-spacing-md) + env(safe-area-inset-left))",
}}
>
{children}
</AppShellMain>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider>
@@ -1,10 +0,0 @@
import type { PropsWithChildren } from "react";
import type { Viewport } from "next";
export const viewport: Viewport = {
viewportFit: "cover",
};
export default function BoardsLayout({ children }: PropsWithChildren) {
return children;
}
@@ -11,7 +11,6 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetDisplayMode } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
@@ -24,9 +23,6 @@ import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps {
item: SectionItem;
displayMode?: WidgetDisplayMode;
disableContextMenu?: boolean;
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
@@ -35,55 +31,36 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
export const BoardItemContent = ({
item,
displayMode = "default",
disableContextMenu = false,
widgetStateRef: externalWidgetStateRef,
}: BoardItemContentProps) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const internalWidgetStateRef = useRef<Record<string, unknown> | null>(null);
const widgetStateRef = externalWidgetStateRef ?? internalWidgetStateRef;
const content = (
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent
item={item}
width={width}
height={height}
displayMode={displayMode}
widgetStateRef={widgetStateRef}
/>
</Card>
);
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
{disableContextMenu ? (
content
) : (
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
{content}
</WidgetContextMenu>
)}
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -113,11 +90,10 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
displayMode: WidgetDisplayMode;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => {
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -170,7 +146,6 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) =
},
})
}
displayMode={displayMode}
{...dimensions}
/>
</ErrorBoundary>
@@ -1,43 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useRef, useState } from "react";
import { Skeleton } from "@mantine/core";
import classes from "./mobile-board.module.css";
interface DeferredMobileItemProps extends PropsWithChildren {
eager: boolean;
}
export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => {
const [hasMounted, setHasMounted] = useState(eager);
const wrapperRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (eager || hasMounted) return;
const element = wrapperRef.current;
if (!element || typeof IntersectionObserver === "undefined") {
setHasMounted(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting) return;
setHasMounted(true);
observer.disconnect();
},
{ rootMargin: "200% 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [eager, hasMounted]);
return (
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
{hasMounted ? children : <Skeleton h="100%" w="100%" radius="var(--mantine-radius-md)" aria-hidden />}
</div>
);
};
@@ -1,61 +1,20 @@
.stream {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 10px;
overflow-x: clip;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
grid-auto-flow: row;
grid-auto-rows: min(calc((100vw - 42px - env(safe-area-inset-left) - env(safe-area-inset-right)) / 2), 12rem);
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
gap: 10px;
padding-bottom: 10px;
}
.item {
position: relative;
min-width: 0;
min-height: 0;
content-visibility: auto;
contain-intrinsic-size: auto 12rem;
}
.deferredItem {
.item > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
}
.deferredItem > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
overflow: hidden;
}
.sectionHeading {
scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md));
padding-block: var(--mantine-spacing-xs);
outline-offset: 4px;
}
.empty {
min-height: min(60dvh, 32rem);
padding: var(--mantine-spacing-xl);
}
.widgetAction {
position: absolute;
z-index: 11;
top: 4px;
right: 4px;
border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent);
box-shadow: var(--mantine-shadow-sm);
}
.detailContent,
.detailContent > :global(.grid-stack-item-content) {
width: 100%;
height: 100%;
min-height: 24rem;
overflow-y: auto;
}
@@ -2,98 +2,21 @@
import type { CSSProperties, RefObject } from "react";
import { useMemo, useRef } from "react";
import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { IconLayoutBoard } from "@tabler/icons-react";
import { Box } from "@mantine/core";
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { SectionProvider } from "../sections/section-context";
import { DeferredMobileItem } from "./deferred-mobile-item";
import classes from "./mobile-board.module.css";
import { MobileWidgetActions } from "./mobile-widget-actions";
import {
createMobileBoardElements,
createMobileBoardItems,
getMobileRootSection,
mobileColumnCount,
} from "./mobile-layout";
import type { MobileBoardElement, MobileBoardSectionHeading } from "./mobile-layout";
import { resolveMobileItemPresentation } from "./mobile-presentation";
type MobileBoardRenderBlock =
| MobileBoardSectionHeading
| {
type: "items";
id: string;
items: SectionItem[];
};
const createRenderBlocks = (elements: MobileBoardElement[]): MobileBoardRenderBlock[] => {
const blocks: MobileBoardRenderBlock[] = [];
for (const element of elements) {
if (element.type === "sectionHeading") {
blocks.push(element);
continue;
}
const previousBlock = blocks.at(-1);
if (previousBlock?.type === "items") {
previousBlock.items.push(element);
} else {
blocks.push({ type: "items", id: `items-${element.id}`, items: [element] });
}
}
return blocks;
};
const getMobilePresentation = (item: SectionItem) => {
const definition = widgetImports[item.kind].definition;
return resolveMobileItemPresentation(item, "mobile" in definition ? definition.mobile : undefined);
};
const MobileBoardItem = ({ item }: { item: SectionItem }) => {
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const presentation = getMobilePresentation(item);
return (
<Box
className={classes.item}
style={{ gridColumn: `span ${presentation.width}`, gridRow: `span ${presentation.height}` }}
data-mobile-board-item={item.id}
data-mobile-display-mode={presentation.displayMode}
>
<DeferredMobileItem eager={presentation.eager}>
<BoardItemContent
item={item}
displayMode={presentation.displayMode}
disableContextMenu
widgetStateRef={widgetStateRef}
/>
<MobileWidgetActions
item={item}
supportsDetails={presentation.supportsDetails}
widgetStateRef={widgetStateRef}
/>
</DeferredMobileItem>
</Box>
);
};
import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout";
export const MobileBoard = () => {
const board = useRequiredBoard();
const t = useI18n();
const desktopLayout = getDesktopLayout(board);
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]);
const renderBlocks = useMemo(() => createRenderBlocks(elements), [elements]);
const rootSection = getMobileRootSection(board);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
@@ -109,47 +32,21 @@ export const MobileBoard = () => {
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
}}
>
<Box className={classes.stream} data-mobile-board>
{elements.length === 0 ? (
<Center className={classes.empty}>
<Stack align="center" gap="xs" ta="center" maw={360}>
<ThemeIcon size={48} radius="xl" variant="light">
<IconLayoutBoard size={24} />
</ThemeIcon>
<Title order={2} size="h3">
{t("board.mobile.empty.title")}
</Title>
<Text c="dimmed" size="sm">
{t("board.mobile.empty.description")}
</Text>
</Stack>
</Center>
) : (
renderBlocks.map((block) =>
block.type === "sectionHeading" ? (
<Title
key={block.id}
id={block.anchorId}
order={2}
size="h4"
className={classes.sectionHeading}
tabIndex={-1}
>
{block.title}
</Title>
) : (
<Box
key={block.id}
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
>
{block.items.map((item) => (
<MobileBoardItem key={item.id} item={item} />
))}
</Box>
),
)
)}
<Box
className={classes.grid}
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
data-mobile-board
>
{items.map((item) => (
<Box
key={item.id}
className={classes.item}
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
data-mobile-board-item={item.id}
>
<BoardItemContent item={item} />
</Box>
))}
</Box>
</SectionProvider>
</ReadOnlyEditModeProvider>
@@ -5,12 +5,7 @@ import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import {
createMobileBoardElements,
createMobileBoardItems,
getMobileRootSection,
getMobileSectionAnchorId,
} from "./mobile-layout";
import { createMobileBoardItems, getMobileRootSection } from "./mobile-layout";
describe("createMobileBoardItems", () => {
test("selects the first root section in visual order", () => {
@@ -98,86 +93,4 @@ describe("createMobileBoardItems", () => {
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
expect(result[1]).toMatchObject({ width: 1, height: 1 });
});
test("emits named category and dynamic section headings in visual order", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const category = new CategorySectionMockBuilder({ id: "category", name: " Services ", yOffset: 0 }).build();
const dynamicSection = new DynamicSectionMockBuilder({
id: "dynamic",
options: { title: "Monitoring", borderColor: "", customCssClasses: [] },
})
.addLayout({ layoutId: desktopLayoutId, parentSectionId: category.id, xOffset: 1, yOffset: 0 })
.build();
const nestedDynamicSection = new DynamicSectionMockBuilder({
id: "nested-dynamic",
options: { title: "Details", borderColor: "", customCssClasses: [] },
})
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
.build();
board.sections.push(category, dynamicSection, nestedDynamicSection);
board.items.push(
new ItemMockBuilder({ id: "before-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id, xOffset: 0, yOffset: 0 })
.build(),
new ItemMockBuilder({ id: "inside-dynamic" })
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id, xOffset: 0, yOffset: 0 })
.build(),
new ItemMockBuilder({ id: "inside-nested" })
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
.build(),
);
const result = createMobileBoardElements(board, desktopLayoutId);
expect(
result.map((element) => (element.type === "sectionHeading" ? `heading:${element.title}` : `item:${element.id}`)),
).toEqual([
"heading:Services",
"item:before-dynamic",
"heading:Monitoring",
"item:inside-dynamic",
"heading:Details",
"item:inside-nested",
]);
expect(result[0]).toMatchObject({
type: "sectionHeading",
sectionId: category.id,
anchorId: getMobileSectionAnchorId(category.id),
});
});
test("omits headings for empty and unnamed sections while preserving their items", () => {
const board = new BoardMockBuilder().build();
const desktopLayoutId = board.layouts.at(0)?.id;
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
const emptyNamedCategory = new CategorySectionMockBuilder({
id: "empty-category",
name: "Empty",
yOffset: 0,
}).build();
const unnamedCategory = new CategorySectionMockBuilder({ id: "unnamed-category", name: " ", yOffset: 1 }).build();
const unnamedDynamicSection = new DynamicSectionMockBuilder({ id: "unnamed-dynamic" })
.addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id })
.build();
board.sections.push(emptyNamedCategory, unnamedCategory, unnamedDynamicSection);
board.items.push(
new ItemMockBuilder({ id: "inside-unnamed" })
.addLayout({ layoutId: desktopLayoutId, sectionId: unnamedDynamicSection.id })
.build(),
new ItemMockBuilder({ id: "orphan" })
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section" })
.build(),
);
const result = createMobileBoardElements(board, desktopLayoutId);
expect(result.map((element) => element.type)).toEqual(["item", "item"]);
expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]);
});
});
@@ -1,8 +1,7 @@
import type { Board, CategorySection, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
export const mobileColumnCount = 2;
const mobileMaxHeight = 3;
const mobileSectionAnchorPrefix = "mobile-board-section-";
type PositionedElement = DynamicSectionItem | SectionItem;
type PositionedLayout = {
@@ -13,16 +12,6 @@ type PositionedLayout = {
height: number;
};
export type MobileBoardSectionHeading = {
type: "sectionHeading";
id: string;
sectionId: string;
title: string;
anchorId: string;
};
export type MobileBoardElement = SectionItem | MobileBoardSectionHeading;
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
@@ -39,8 +28,6 @@ export const getMobileRootSection = (board: Board) =>
yOffset: 0,
};
export const getMobileSectionAnchorId = (sectionId: string) => `${mobileSectionAnchorPrefix}${sectionId}`;
const getLayout = <TLayout extends PositionedLayout>(
layouts: TLayout[],
desktopLayoutId: string,
@@ -51,20 +38,7 @@ const getLayout = <TLayout extends PositionedLayout>(
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
)[0];
const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => {
const title = (section.kind === "category" ? section.name : section.options.title).trim();
if (title.length === 0) return null;
return {
type: "sectionHeading",
id: `section-heading-${section.id}`,
sectionId: section.id,
title,
anchorId: getMobileSectionAnchorId(section.id),
};
};
export const createMobileBoardElements = (board: Board, desktopLayoutId: string): MobileBoardElement[] => {
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
const rootSections = getMobileRootSections(board);
const defaultSectionId = rootSections[0]?.id ?? "mobile";
const layoutPriority = new Map(
@@ -106,66 +80,38 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string)
elementsBySection.set(parentSectionId, elements);
}
const result: MobileBoardElement[] = [];
const result: SectionItem[] = [];
const visitedItems = new Set<string>();
const visitedSections = new Set<string>();
const createSectionElements = (
section: CategorySection | DynamicSectionItem | (typeof rootSections)[number],
): { elements: MobileBoardElement[]; items: SectionItem[] } => {
if (visitedSections.has(section.id)) return { elements: [], items: [] };
const sectionId = section.id;
const appendSectionItems = (sectionId: string) => {
if (visitedSections.has(sectionId)) return;
visitedSections.add(sectionId);
const sectionElements: MobileBoardElement[] = [];
const sectionItems: SectionItem[] = [];
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
if (element.type === "section") {
const nestedResult = createSectionElements(element);
sectionElements.push(...nestedResult.elements);
sectionItems.push(...nestedResult.items);
appendSectionItems(element.id);
continue;
}
if (!visitedItems.has(element.id)) {
visitedItems.add(element.id);
sectionElements.push(element);
sectionItems.push(element);
result.push(element);
}
}
if (sectionItems.length === 0) return { elements: [], items: [] };
const heading = section.kind === "empty" ? null : createSectionHeading(section);
return {
elements: heading ? [heading, ...sectionElements] : sectionElements,
items: sectionItems,
};
};
rootSections.forEach((section) => result.push(...createSectionElements(section).elements));
dynamicSections
.toSorted(comparePosition)
.forEach((section) => result.push(...createSectionElements(section).elements));
rootSections.forEach((section) => appendSectionItems(section.id));
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
items.toSorted(comparePosition).forEach((item) => {
if (!visitedItems.has(item.id)) result.push(item);
});
let itemIndex = 0;
return result.map((element) => {
if (element.type === "sectionHeading") return element;
return {
...element,
xOffset: 0,
yOffset: itemIndex++,
width: Math.max(1, Math.min(element.width, mobileColumnCount)),
height: Math.max(1, Math.min(element.height, mobileMaxHeight)),
};
});
return result.map((item, index) => ({
...item,
xOffset: 0,
yOffset: index,
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
}));
};
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] =>
createMobileBoardElements(board, desktopLayoutId).filter(
(element): element is SectionItem => element.type === "item",
);
@@ -1,44 +0,0 @@
import { describe, expect, test } from "vitest";
import { resolveMobileItemPresentation } from "./mobile-presentation";
describe("resolveMobileItemPresentation", () => {
test("renders apps as eager compact tiles", () => {
expect(resolveMobileItemPresentation({ kind: "app", height: 8 }, undefined)).toMatchObject({
width: 1,
height: 1,
eager: true,
supportsDetails: false,
});
});
test("defaults widgets to full width and clamps desktop height", () => {
expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({
width: 2,
height: 3,
eager: false,
supportsDetails: true,
});
});
test("prefers explicit presentation metadata", () => {
expect(
resolveMobileItemPresentation(
{ kind: "clock", height: 3 },
{
width: 1,
height: 1,
supportsCompactSummary: true,
supportsDetailView: false,
eager: true,
},
),
).toEqual({
width: 1,
height: 1,
displayMode: "mobileSummary",
supportsDetails: false,
eager: true,
});
});
});
@@ -1,13 +0,0 @@
import type { WidgetKind } from "@homarr/definitions";
import type { WidgetMobilePresentation } from "@homarr/widgets";
export const resolveMobileItemPresentation = (
item: { kind: WidgetKind; height: number },
mobile: WidgetMobilePresentation | undefined,
) => ({
width: mobile?.width ?? (item.kind === "app" ? 1 : 2),
height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))),
displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const),
supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app",
eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"),
});
@@ -1,166 +0,0 @@
"use client";
import type { MutableRefObject } from "react";
import { useMemo } from "react";
import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react";
import type { QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { useItemActions } from "../items/item-actions";
import classes from "./mobile-board.module.css";
interface MobileWidgetActionsProps {
item: SectionItem;
supportsDetails: boolean;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => {
const [actionsOpened, actionsDisclosure] = useDisclosure(false);
const [detailsOpened, detailsDisclosure] = useDisclosure(false);
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { updateItemOptions } = useItemActions();
const definition = widgetImports[item.kind].definition;
const queryClient = useQueryClient();
const queryKey = useMemo(
() => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]),
[definition, item.kind],
);
const isFetching = useIsFetching({ queryKey }) > 0;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, item.options, settings],
);
const contextActions = useMemo(() => {
if (!("contextActions" in definition) || typeof definition.contextActions !== "function") return [];
return definition.contextActions({
options,
setOptions: (partial: Record<string, unknown>) =>
updateItemOptions({
itemId: item.id,
newOptions: { ...options, ...partial },
}),
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
} as never) as WidgetContextMenuAction[];
}, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]);
const visibleContextActions = contextActions.filter((action) => !action.hidden);
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0;
if (!hasActions) return null;
const refresh = () => {
void queryClient.refetchQueries({ queryKey, type: "all" });
actionsDisclosure.close();
};
return (
<>
<ActionIcon
className={classes.widgetAction}
variant="filled"
color="dark"
radius="xl"
size={44}
aria-label={t("board.mobile.actions")}
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 }}
>
<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={() => {
actionsDisclosure.close();
detailsDisclosure.open();
}}
>
{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>
);
})}
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={refresh}
>
{t("item.menu.label.refresh")}
</Button>
</Stack>
</Drawer>
<Modal
opened={detailsOpened}
onClose={detailsDisclosure.close}
title={title}
fullScreen
padding="md"
styles={{
body: {
height: "calc(100dvh - 4rem)",
overflowY: "auto",
paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu widgetStateRef={widgetStateRef} />
</Box>
</Modal>
</>
);
};
@@ -1,58 +0,0 @@
import { describe, expect, test } from "vitest";
import { resolveMobileBoardViewport } from "./use-mobile-board";
describe("resolveMobileBoardViewport", () => {
test("keeps phones mobile regardless of their viewport width", () => {
expect(
resolveMobileBoardViewport({
deviceClass: "phone",
matchesMobileWidth: false,
hasResolvedClientWidth: true,
}),
).toEqual({
deviceClass: "phone",
isMobile: true,
isResolved: true,
});
});
test.each([
[true, true],
[false, false],
])("uses the 48em query for tablets (matches: %s)", (matchesMobileWidth, expectedIsMobile) => {
expect(
resolveMobileBoardViewport({
deviceClass: "tablet",
matchesMobileWidth,
hasResolvedClientWidth: true,
}).isMobile,
).toBe(expectedIsMobile);
});
test("keeps tablets unresolved until their client width is known", () => {
expect(
resolveMobileBoardViewport({
deviceClass: "tablet",
matchesMobileWidth: false,
hasResolvedClientWidth: false,
}).isResolved,
).toBe(false);
});
test.each([
[true, true],
[false, false],
])("keeps desktop resize behavior (matches: %s)", (matchesMobileWidth, expectedIsMobile) => {
expect(
resolveMobileBoardViewport({
deviceClass: "desktop",
matchesMobileWidth,
hasResolvedClientWidth: true,
}),
).toMatchObject({
isMobile: expectedIsMobile,
isResolved: true,
});
});
});
@@ -1,97 +1,21 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { Box, Group, Skeleton, Stack } from "@mantine/core";
import { createContext, useContext } from "react";
import { useMediaQuery } from "@mantine/hooks";
export const mobileBoardMediaQuery = "(max-width: 48em)";
export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop";
interface MobileBoardViewport {
deviceClass: MobileBoardDeviceClass;
isMobile: boolean;
isResolved: boolean;
}
const MobileBoardViewportContext = createContext<MobileBoardViewport>({
deviceClass: "desktop",
isMobile: false,
isResolved: true,
});
export const resolveMobileBoardViewport = ({
deviceClass,
matchesMobileWidth,
hasResolvedClientWidth,
}: {
deviceClass: MobileBoardDeviceClass;
matchesMobileWidth: boolean;
hasResolvedClientWidth: boolean;
}): MobileBoardViewport => ({
deviceClass,
isMobile: deviceClass === "phone" || matchesMobileWidth,
isResolved: deviceClass !== "tablet" || hasResolvedClientWidth,
});
const MobileBoardViewportSkeleton = () => (
<Box
aria-busy
mih="100dvh"
p="md"
pt="calc(var(--mantine-spacing-md) + env(safe-area-inset-top))"
pr="calc(var(--mantine-spacing-md) + env(safe-area-inset-right))"
pb="calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))"
pl="calc(var(--mantine-spacing-md) + env(safe-area-inset-left))"
>
<Group h={44} justify="space-between" wrap="nowrap">
<Skeleton h={32} w="min(45vw, 12rem)" />
<Group gap="sm" wrap="nowrap">
<Skeleton circle h={44} />
<Skeleton circle h={44} />
</Group>
</Group>
<Stack gap="md" mt="md">
<Group align="stretch" grow wrap="nowrap">
<Skeleton h={150} />
<Skeleton h={150} />
</Group>
<Skeleton h={220} />
<Group align="stretch" grow wrap="nowrap">
<Skeleton h={150} />
<Skeleton h={150} />
</Group>
</Stack>
</Box>
);
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
export const MobileBoardViewportProvider = ({
initialDeviceClass,
initialIsMobile,
children,
}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => {
const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false);
const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", {
getInitialValueInEffect: true,
});
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
);
useEffect(() => {
setHasResolvedClientWidth(true);
}, []);
const viewport = resolveMobileBoardViewport({
deviceClass: initialDeviceClass,
matchesMobileWidth,
hasResolvedClientWidth,
});
return (
<MobileBoardViewportContext.Provider value={viewport}>
{viewport.isResolved ? children : <MobileBoardViewportSkeleton />}
</MobileBoardViewportContext.Provider>
);
export const useIsMobileBoard = () => {
const initialIsMobile = useContext(InitialMobileBoardContext);
return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
};
export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext);
export const useIsMobileBoard = () => useMobileBoardViewport().isMobile;
+9 -35
View File
@@ -6,49 +6,25 @@ import { Link } from "@homarr/ui";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { ResponsiveUserButton } from "./header/responsive-user";
import { TourTarget } from "./header/tour-target";
import { UserButton } from "./header/user";
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
interface Props {
logo?: ReactNode;
logoHref?: string | null;
actions?: ReactNode;
hasNavigation?: boolean;
withSafeArea?: boolean;
hideUserOnMobileBoard?: boolean;
}
export const MainHeader = ({
logo,
logoHref = "/",
actions,
hasNavigation = true,
withSafeArea = false,
hideUserOnMobileBoard = false,
}: Props) => {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<AppShellHeader
maw="100vw"
zIndex={201}
style={{
overflowX: "hidden",
paddingTop: withSafeArea ? "env(safe-area-inset-top)" : undefined,
paddingRight: withSafeArea ? "env(safe-area-inset-right)" : undefined,
paddingLeft: withSafeArea ? "env(safe-area-inset-left)" : undefined,
}}
>
<Group h="100%" gap="md" px="md" justify="apart" wrap="nowrap">
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
{logoHref === null ? (
(logo ?? <HomarrLogoWithTitle size="md" />)
) : (
<UnstyledButton component={Link} href={logoHref}>
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
)}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
@@ -56,11 +32,9 @@ export const MainHeader = ({
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<ResponsiveUserButton hideOnMobileBoard={hideUserOnMobileBoard}>
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</ResponsiveUserButton>
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<Spotlight />
@@ -20,7 +20,7 @@ const headerButtonActionIconProps: ActionIconProps = {
variant: "subtle",
style: { border: "none" },
color: "gray",
size: 44,
size: "lg",
};
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((props, ref) => {
@@ -29,15 +29,15 @@ export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>((pr
<ActionIcon
ref={ref as ForwardedRef<HTMLAnchorElement>}
component={Link}
{...headerButtonActionIconProps}
{...props}
{...headerButtonActionIconProps}
>
{props.children}
</ActionIcon>
);
}
return (
<ActionIcon ref={ref} {...headerButtonActionIconProps} {...props}>
<ActionIcon ref={ref} {...props} {...headerButtonActionIconProps}>
{props.children}
</ActionIcon>
);
@@ -1,13 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useIsMobileBoard } from "../../board/use-mobile-board";
export const ResponsiveUserButton = ({
hideOnMobileBoard,
children,
}: PropsWithChildren<{ hideOnMobileBoard: boolean }>) => {
const isMobileBoard = useIsMobileBoard();
return hideOnMobileBoard && isMobileBoard ? null : children;
};
@@ -28,10 +28,8 @@ export const DesktopSearchInput = () => {
};
export const MobileSearchButton = () => {
const t = useI18n();
return (
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch} aria-label={t("search.placeholder")}>
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
<IconSearch size={20} stroke={1.5} />
</HeaderButton>
);
@@ -4,20 +4,19 @@ import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { getI18n } from "@homarr/translation/server";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
export const UserButton = async () => {
const [session, t] = await Promise.all([auth(), getI18n()]);
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton aria-label={t("board.mobile.profile")}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
<CurrentUserAvatar size="md" />
+1 -3
View File
@@ -10,13 +10,11 @@ import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
hasHeader?: boolean;
hasNavigation?: boolean;
withSafeArea?: boolean;
}
export const ClientShell = ({
hasHeader = true,
hasNavigation = true,
withSafeArea = false,
children,
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
@@ -25,7 +23,7 @@ export const ClientShell = ({
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: withSafeArea ? "calc(60px + env(safe-area-inset-top))" : 60 } : undefined}
header={hasHeader ? { height: 60 } : undefined}
navbar={
hasNavigation
? {