Compare commits

...
Author SHA1 Message Date
ajnart 7e420bc087 refactor: share mobile breakpoint handling 2026-08-27 09:15:33 +02:00
ajnart 322d4a7fc4 fix: keep mobile header logo condensed 2026-08-27 07:26:34 +02:00
ajnart 63e1bc7f6c fix: improve mobile header and board menus 2026-08-27 07:24:40 +02:00
13 changed files with 208 additions and 64 deletions

No files matched your search

@@ -111,7 +111,7 @@ export const createBoardLayout = <TParams extends Params>({
<ClientShell hasNavigation={false}> <ClientShell hasNavigation={false}>
<MainHeader <MainHeader
logo={<BoardLogo size={appShellLogoHeight} />} logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" hideTitleOnMobile />} logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions} actions={headerActions}
boardEditAction={headerBoardEditAction} boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction} boardSettingsAction={headerBoardSettingsAction}
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -12,6 +12,7 @@ import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { UserAvatar } from "@homarr/ui";
import { useIsMobile } from "@homarr/ui/hooks";
interface UserProfileAvatarForm { interface UserProfileAvatarForm {
user: RouterOutputs["user"]["getById"]; user: RouterOutputs["user"]["getById"];
@@ -25,7 +26,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
}, },
}); });
const [opened, { toggle }] = useDisclosure(false); const [opened, { toggle }] = useDisclosure(false);
const isMobile = useMediaQuery("(max-width: 48em)"); const isMobile = useIsMobile();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common"); const tCommon = useI18n("common");
const tManageAvatar = useI18n("user.action.manageAvatar"); const tManageAvatar = useI18n("user.action.manageAvatar");
@@ -15,6 +15,10 @@
outline: none; outline: none;
} }
.results {
min-height: 0;
}
.searchInput { .searchInput {
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
@@ -44,3 +48,19 @@
border-color: var(--mantine-primary-color-filled); border-color: var(--mantine-primary-color-filled);
box-shadow: var(--mantine-shadow-md); box-shadow: var(--mantine-shadow-md);
} }
@media (max-width: $mantine-breakpoint-sm) {
.modalContent {
height: calc(100dvh - var(--modal-y-offset) * 2);
}
.modalBody,
.content {
min-height: 0;
height: 100%;
}
.results {
flex: 1;
}
}
@@ -228,7 +228,13 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
radius="xl" radius="xl"
classNames={{ input: classes.searchInput }} classNames={{ input: classes.searchInput }}
/> />
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never"> <ScrollArea.Autosize
mah="min(80vh, 42rem)"
type="never"
scrollbars="y"
overscrollBehavior="contain"
className={classes.results}
>
{results} {results}
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Stack> </Stack>
@@ -0,0 +1,14 @@
.mobileDrawerContent {
height: min(80dvh, rem(640px));
max-height: 80dvh;
overscroll-behavior: contain;
}
.mobileDrawerBody {
padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom));
}
.mobileActionList {
display: flex;
flex-direction: column;
}
@@ -2,7 +2,7 @@
import type { MutableRefObject, ReactNode, RefObject } from "react"; import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react"; import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query";
@@ -20,6 +20,7 @@ import { getWidgetName } from "@homarr/definitions";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
@@ -31,6 +32,9 @@ import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope"; import { matchesWidgetItemQuery } from "./widget-query-scope";
import classes from "./widget-context-menu.module.css";
type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }];
interface WidgetContextMenuProps { interface WidgetContextMenuProps {
item: SectionItem; item: SectionItem;
@@ -134,11 +138,12 @@ export const WidgetContextMenu = ({
[item.id, persistBoard], [item.id, persistBoard],
); );
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => { const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return []; if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>; const rawOptions = definition.createOptions(settings) as unknown as Record<string, ToggleOption[1]>;
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu); return Object.entries(rawOptions).filter(
([, option]) => option.type === "switch" && !option.skipContextMenu,
) as ToggleOption[];
}, [canConfigureWidget, definition, settings]); }, [canConfigureWidget, definition, settings]);
const widgetContextActions = const widgetContextActions =
@@ -222,6 +227,18 @@ export const WidgetContextMenu = ({
[setItemOptions], [setItemOptions],
); );
const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return;
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}, [item.id, openAdvancedFocus, sourceRef]);
const handleCloseMenu = useCallback(() => {
setMenuOpened(false);
}, []);
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
return <>{children}</>; return <>{children}</>;
} }
@@ -239,21 +256,10 @@ export const WidgetContextMenu = ({
onChange={setMenuOpened} onChange={setMenuOpened}
> >
<Menu.ContextMenu>{children}</Menu.ContextMenu> <Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown> <WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
{canOpenAdvancedFocus && ( {canOpenAdvancedFocus && (
<> <>
<Menu.Item <Menu.Item closeMenuOnClick leftSection={<IconMaximize size={16} />} onClick={handleOpenAdvancedFocus}>
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")} {t("item.advancedFocus.open")}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
@@ -319,11 +325,39 @@ export const WidgetContextMenu = ({
> >
{tMenu("settings")} {tMenu("settings")}
</Menu.Item> </Menu.Item>
</Menu.Dropdown> </WidgetContextMenuDropdown>
</Menu> </Menu>
); );
}; };
interface WidgetContextMenuDropdownProps {
opened: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => {
const isMobile = useIsMobile();
if (!isMobile) return <Menu.Dropdown>{children}</Menu.Dropdown>;
return (
<Drawer
opened={opened}
onClose={onClose}
position="bottom"
size="min(80dvh, 40rem)"
title={title}
classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }}
>
<div className={classes.mobileActionList} role="menu" aria-orientation="vertical" data-menu-dropdown>
{children}
</div>
</Drawer>
);
};
interface WidgetQueryStatusProps { interface WidgetQueryStatusProps {
queryClient: QueryClient; queryClient: QueryClient;
matchesQuery: (queryKey: QueryKey) => boolean; matchesQuery: (queryKey: QueryKey) => boolean;
+2 -1
View File
@@ -27,11 +27,12 @@ export const MainHeader = async ({
const session = await auth(); const session = await auth();
const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isAdmin = Boolean(session?.user.permissions.includes("admin"));
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const resolvedLogoWithTitle = logoWithTitle ?? <HomarrLogoWithTitle size="md" />;
return ( return (
<ConfigurableHeader <ConfigurableHeader
logo={logo ?? <HomarrLogo size={appShellLogoHeight} />} logo={logo ?? <HomarrLogo size={appShellLogoHeight} />}
logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />} logoWithTitle={resolvedLogoWithTitle}
actions={actions} actions={actions}
boardEditAction={boardEditAction} boardEditAction={boardEditAction}
boardSettingsAction={boardSettingsAction} boardSettingsAction={boardSettingsAction}
@@ -2,6 +2,15 @@
overflow: hidden; overflow: hidden;
} }
.header[data-desktop-visible="false"] {
display: none;
}
.desktopContent {
width: 100%;
height: 100%;
}
.content { .content {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -64,6 +73,41 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.mobileContent {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs);
width: 100%;
height: 100%;
padding-inline: var(--mantine-spacing-xs);
}
.mobileIdentity,
.mobileActions {
min-width: 0;
}
.mobileIdentity {
flex: 1 1 auto;
overflow: hidden;
}
.mobileIdentity > :last-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
.mobileIdentity h2 {
overflow: hidden;
text-overflow: ellipsis;
}
.mobileActions {
flex: 0 0 auto;
}
.floatingControls { .floatingControls {
position: fixed; position: fixed;
z-index: var(--homarr-z-index-board-header); z-index: var(--homarr-z-index-board-header);
@@ -111,18 +155,21 @@
} }
@media (max-width: $mantine-breakpoint-sm) { @media (max-width: $mantine-breakpoint-sm) {
.content { .header[data-desktop-visible="false"] {
gap: var(--mantine-spacing-xs); display: block;
padding-inline: var(--mantine-spacing-xs);
} }
.zone { .desktopContent {
gap: var(--mantine-spacing-xs); display: none;
} }
.search { .mobileContent {
width: auto; display: flex;
flex: 0 0 auto; }
.floatingControls,
.floatingControlsCorner {
display: none;
} }
} }
@@ -2,15 +2,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
AppShellHeader,
Avatar,
Group,
Indicator,
Loader,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import { import {
IconBrandDocker, IconBrandDocker,
IconHome, IconHome,
@@ -116,14 +108,15 @@ export const ConfigurableHeader = ({
return ( return (
<> <>
{headerPreferences.visible ? ( <AppShellHeader
<AppShellHeader maw="100vw"
maw="100vw" zIndex="var(--homarr-z-index-board-header)"
zIndex="var(--homarr-z-index-board-header)" className={classes.header}
className={classes.header} data-desktop-visible={headerPreferences.visible}
data-advanced-focus-background data-advanced-focus-background
data-app-shell-header data-app-shell-header
> >
<div className={classes.desktopContent}>
<div className={classes.content}> <div className={classes.content}>
<div className={classes.zone} data-zone="left"> <div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
@@ -137,8 +130,31 @@ export const ConfigurableHeader = ({
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div> </div>
</div> </div>
</AppShellHeader> </div>
) : ( <div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} />
</Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap">
{actions}
{boardEditAction}
{boardSettingsAction}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group>
</div>
</AppShellHeader>
{!headerPreferences.visible ? (
<FloatingHeaderControls> <FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
<UserButtonClient <UserButtonClient
@@ -148,7 +164,7 @@ export const ConfigurableHeader = ({
boardSwitcher={boardSwitcher} boardSwitcher={boardSwitcher}
/> />
</FloatingHeaderControls> </FloatingHeaderControls>
)} ) : null}
<LazySpotlight /> <LazySpotlight />
</> </>
); );
@@ -291,7 +307,7 @@ const HeaderItem = ({
<Tooltip label={board.name}> <Tooltip label={board.name}>
<HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}> <HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}>
<Avatar src={board.logoImageUrl} size={22} radius="sm"> <Avatar src={board.logoImageUrl} size={22} radius="sm">
<IconLayoutDashboard size={17} stroke={1.5} /> {getBoardInitial(board.name)}
</Avatar> </Avatar>
</HeaderButton> </HeaderButton>
</Tooltip> </Tooltip>
@@ -299,14 +315,7 @@ const HeaderItem = ({
} }
if (item.id === "logo") { if (item.id === "logo") {
return ( return <HeaderLogo display={logoDisplay} logo={logo} logoWithTitle={logoWithTitle} label={label("logo")} />;
<HeaderLogo
display={logoDisplay}
logo={logo}
logoWithTitle={logoWithTitle}
label={label("logo")}
/>
);
} }
if (item.id === "boardEdit") return boardEditAction; if (item.id === "boardEdit") return boardEditAction;
@@ -416,3 +425,5 @@ const HeaderItem = ({
</TourTarget> </TourTarget>
); );
}; };
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?";
+2 -2
View File
@@ -22,12 +22,12 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom); const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps(); const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings(); const { headerPreferences } = useSettings();
const isHeaderVisible = hasHeader && headerPreferences.visible; const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 };
return ( return (
<AppShell <AppShell
{...backgroundProps} {...backgroundProps}
header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined} header={hasHeader ? { height: headerHeight } : undefined}
navbar={ navbar={
hasNavigation hasNavigation
? { ? {
@@ -4,7 +4,6 @@ import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
import { useMediaQuery } from "@mantine/hooks";
import { import {
IconAffiliateFilled, IconAffiliateFilled,
IconAppsFilled, IconAppsFilled,
@@ -16,6 +15,7 @@ import {
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useIsMobile } from "@homarr/ui/hooks";
import { TourTargetsProvider } from "~/components/layout/header/tour-target"; import { TourTargetsProvider } from "~/components/layout/header/tour-target";
import { TourShell } from "./tour-shell"; import { TourShell } from "./tour-shell";
@@ -48,7 +48,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => { export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
const t = useI18n("onboardingTour.manage"); const t = useI18n("onboardingTour.manage");
const { mutate: completeTour } = clientApi.user.completeTour.useMutation(); const { mutate: completeTour } = clientApi.user.completeTour.useMutation();
const isMobile = useMediaQuery("(max-width: 48em)"); const isMobile = useIsMobile();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
+1
View File
@@ -1 +1,2 @@
export * from "./use-is-mobile";
export * from "./use-translated-mantine-react-table"; export * from "./use-translated-mantine-react-table";
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { useMantineTheme } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
export const useIsMobile = () => {
const theme = useMantineTheme();
return useMediaQuery(`(max-width: ${theme.breakpoints.sm})`);
};