Skip to content

Commit 3f3ba61

Browse files
committed
feat: improve mobile board experience
1 parent 91efee4 commit 3f3ba61

46 files changed

Lines changed: 1454 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/docs/management/boards/index.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,13 @@ Changing the column count automatically adjusts item positions and sizes to fit
8383

8484
#### Mobile layout
8585

86-
Mobile boards use an automatic, read-only layout with two columns. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high.
86+
Phones use an automatic, read-only layout with exactly two columns in both portrait and landscape. Tablets use this layout at widths up to 48em. Items follow the visual order of the desktop layout and are limited to two columns wide and three rows high.
8787

88-
Categories and dynamic sections are not displayed as containers on mobile. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. To rearrange, add, remove, or resize board items, open the board on a larger screen.
88+
Named categories and dynamic sections appear as lightweight headings and section shortcuts, but never as layout containers. Their items are included directly in the automatic grid, even when a category is collapsed on desktop. Layout and widget configuration stay locked on mobile; links, refresh actions, automations, and widget details remain interactive.
89+
90+
App items use compact tiles. Widgets can opt into a compact summary, and larger widgets provide a full-screen detail view. Widgets farther down a long board are loaded shortly before they enter the viewport while their space is preserved with a skeleton.
91+
92+
To rearrange, add, remove, resize, or configure board items, open the board on a larger screen.
8993

9094
### Background
9195
#### Background image URL

apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
4444
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
4545
getInitialBoardAsync: getInitialBoard,
4646
withTour: true,
47+
mobileProfileInActions: true,
4748
}),
4849
// eslint-disable-next-line no-restricted-syntax
4950
page: async ({ params }: { params: Promise<TParams> }) => {

apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx

Lines changed: 166 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"use client";
22

3-
import { useCallback, useEffect } from "react";
3+
import { useCallback, useEffect, useMemo } from "react";
44
import { useRouter } from "next/navigation";
55
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
6-
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
7-
import { useHotkeys } from "@mantine/hooks";
6+
import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core";
7+
import { useDisclosure, useHotkeys, useReducedMotion } from "@mantine/hooks";
88
import {
99
IconBox,
1010
IconBoxAlignTop,
@@ -17,11 +17,18 @@ import {
1717
IconReplace,
1818
IconResize,
1919
IconSettings,
20+
IconDotsVertical,
21+
IconHome,
22+
IconLogin,
23+
IconList,
24+
IconLogout,
25+
IconTool,
26+
IconUser,
2027
} from "@tabler/icons-react";
2128

2229
import { clientApi } from "@homarr/api/client";
23-
import { useSession } from "@homarr/auth/client";
24-
import { useRequiredBoard } from "@homarr/boards/context";
30+
import { signOut, useSession } from "@homarr/auth/client";
31+
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
2532
import { useEditMode } from "@homarr/boards/edit-mode";
2633
import { revalidatePathActionAsync } from "@homarr/common/client";
2734
import { env } from "@homarr/common/env";
@@ -32,21 +39,30 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
3239
import { useI18n, useScopedI18n } from "@homarr/translation/client";
3340
import { Link } from "@homarr/ui";
3441

42+
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
3543
import { useItemActions } from "~/components/board/items/item-actions";
3644
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
3745
import { useBoardPermissions } from "~/components/board/permissions/client";
3846
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
3947
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
4048
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
49+
import { createMobileBoardElements } from "~/components/board/mobile/mobile-layout";
4150
import { useIsMobileBoard } from "~/components/board/use-mobile-board";
4251
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
52+
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
53+
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
4354
import { HeaderButton } from "~/components/layout/header/button";
4455

4556
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
4657
const [isEditMode] = useEditMode();
4758
const board = useRequiredBoard();
4859
const { hasChangeAccess } = useBoardPermissions(board);
4960
const isMobile = useIsMobileBoard();
61+
const t = useI18n();
62+
63+
if (isMobile) {
64+
return <MobileMoreMenu showSettings={hasChangeAccess && !demoReadOnly} />;
65+
}
5066

5167
if (!hasChangeAccess) {
5268
return <SelectBoardsMenu />;
@@ -60,7 +76,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
6076

6177
{!demoReadOnly && (
6278
<OnboardingTour.Target id="board-settings">
63-
<HeaderButton href={`/boards/${board.name}/settings`}>
79+
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("item.menu.label.settings")}>
6480
<IconSettings stroke={1.5} />
6581
</HeaderButton>
6682
</OnboardingTour.Target>
@@ -125,7 +141,7 @@ const AddMenu = () => {
125141
return (
126142
<Menu position="bottom-end">
127143
<Menu.Target>
128-
<HeaderButton w="auto" px={4}>
144+
<HeaderButton w="auto" px={4} aria-label={t("common.action.add")}>
129145
<Group gap={4} wrap="nowrap">
130146
<IconPlus stroke={1.5} />
131147
<IconChevronDown color="gray" size={16} />
@@ -164,6 +180,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
164180
const board = useRequiredBoard();
165181
const utils = clientApi.useUtils();
166182
const t = useScopedI18n("board.action.edit");
183+
const tItem = useScopedI18n("item");
167184
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
168185
onSuccess() {
169186
showSuccessNotification({
@@ -203,7 +220,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
203220

204221
return (
205222
<OnboardingTour.Target id="board-edit-mode">
206-
<HeaderButton onClick={toggle} loading={isPending}>
223+
<HeaderButton onClick={toggle} loading={isPending} aria-label={tItem("action.edit")}>
207224
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
208225
</HeaderButton>
209226
</OnboardingTour.Target>
@@ -212,13 +229,14 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden:
212229

213230
const SelectBoardsMenu = () => {
214231
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
232+
const t = useI18n();
215233

216234
return (
217235
<OnboardingTour.Target id="board-switcher">
218236
<Box>
219237
<Menu position="bottom-end">
220238
<Menu.Target>
221-
<HeaderButton w="auto" px={4}>
239+
<HeaderButton w="auto" px={4} aria-label={t("board.mobile.currentBoard")}>
222240
<IconReplace stroke={1.5} />
223241
</HeaderButton>
224242
</Menu.Target>
@@ -242,6 +260,145 @@ const SelectBoardsMenu = () => {
242260
);
243261
};
244262

263+
const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => {
264+
const board = useRequiredBoard();
265+
const { data: session } = useSession();
266+
const { logoutUrl } = useAuthContext();
267+
const t = useI18n();
268+
const tProfile = useScopedI18n("common.userAvatar.menu");
269+
const reduceMotion = useReducedMotion();
270+
const [opened, disclosure] = useDisclosure(false);
271+
const desktopLayout = getDesktopLayout(board);
272+
const sections = useMemo(
273+
() => createMobileBoardElements(board, desktopLayout.id).filter((element) => element.type === "sectionHeading"),
274+
[board, desktopLayout.id],
275+
);
276+
277+
const jumpToSection = (anchorId: string) => {
278+
disclosure.close();
279+
requestAnimationFrame(() => {
280+
const heading = document.getElementById(anchorId);
281+
heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" });
282+
heading?.focus({ preventScroll: true });
283+
});
284+
};
285+
286+
return (
287+
<>
288+
<HeaderButton onClick={disclosure.open} aria-label={t("board.mobile.more")}>
289+
<IconDotsVertical stroke={1.5} />
290+
</HeaderButton>
291+
<Drawer
292+
opened={opened}
293+
onClose={disclosure.close}
294+
title={t("board.mobile.more")}
295+
position="bottom"
296+
size="auto"
297+
padding="md"
298+
overlayProps={{ backgroundOpacity: 0.45, blur: 2 }}
299+
>
300+
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
301+
<Button
302+
component={Link}
303+
href="/boards"
304+
variant="subtle"
305+
size="lg"
306+
justify="flex-start"
307+
leftSection={<IconHome size={20} />}
308+
>
309+
{tProfile("homeBoard")}
310+
</Button>
311+
<CurrentColorSchemeCombobox w="100%" />
312+
<CurrentLanguageCombobox width="100%" withinPortal={false} />
313+
{session ? (
314+
<>
315+
<Button
316+
component={Link}
317+
href={`/manage/users/${session.user.id}/general`}
318+
variant="subtle"
319+
size="lg"
320+
justify="flex-start"
321+
leftSection={<IconUser size={20} />}
322+
>
323+
{tProfile("preferences")}
324+
</Button>
325+
<Button
326+
component={Link}
327+
href="/manage"
328+
variant="subtle"
329+
size="lg"
330+
justify="flex-start"
331+
leftSection={<IconTool size={20} />}
332+
>
333+
{tProfile("management")}
334+
</Button>
335+
<Button
336+
variant="subtle"
337+
color="red"
338+
size="lg"
339+
justify="flex-start"
340+
leftSection={<IconLogout size={20} />}
341+
onClick={() => {
342+
void signOut({ redirect: false }).then(() => {
343+
window.location.assign(logoutUrl ?? "/auth/login");
344+
});
345+
}}
346+
>
347+
{tProfile("logout")}
348+
</Button>
349+
</>
350+
) : (
351+
<Button
352+
component={Link}
353+
href="/auth/login"
354+
variant="subtle"
355+
size="lg"
356+
justify="flex-start"
357+
leftSection={<IconLogin size={20} />}
358+
>
359+
{tProfile("login")}
360+
</Button>
361+
)}
362+
{showSettings && (
363+
<Button
364+
component={Link}
365+
href={`/boards/${board.name}/settings`}
366+
variant="subtle"
367+
size="lg"
368+
justify="flex-start"
369+
leftSection={<IconSettings size={20} />}
370+
>
371+
{t("item.menu.label.settings")}
372+
</Button>
373+
)}
374+
{sections.length >= 2 && (
375+
<>
376+
{showSettings && <Divider />}
377+
<Group gap="xs" px="sm" pt="xs">
378+
<IconList size={18} aria-hidden />
379+
<Text size="sm" fw={600}>
380+
{t("board.mobile.sections")}
381+
</Text>
382+
</Group>
383+
{sections.map((section) => (
384+
<Button
385+
key={section.id}
386+
variant="subtle"
387+
size="lg"
388+
justify="flex-start"
389+
onClick={() => jumpToSection(section.anchorId)}
390+
>
391+
{section.title}
392+
</Button>
393+
))}
394+
</>
395+
)}
396+
</Stack>
397+
</Drawer>
398+
</>
399+
);
400+
};
401+
245402
const anchorSelector = "a[href]:not([target='_blank'])";
246403
const usePreventLeaveWithDirty = (isDirty: boolean) => {
247404
const t = useI18n();
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { Group, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core";
4+
import { IconCheck, IconChevronDown, IconLayoutBoard } from "@tabler/icons-react";
5+
6+
import { clientApi } from "@homarr/api/client";
7+
import { useRequiredBoard } from "@homarr/boards/context";
8+
import { useI18n } from "@homarr/translation/client";
9+
import { Link } from "@homarr/ui";
10+
11+
import { BoardLogo } from "~/components/layout/logo/board-logo";
12+
13+
export const BoardSwitcherLogo = () => {
14+
const board = useRequiredBoard();
15+
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
16+
const t = useI18n();
17+
18+
return (
19+
<Menu position="bottom-start" width={280}>
20+
<Menu.Target>
21+
<UnstyledButton
22+
aria-label={`${t("board.mobile.currentBoard")}: ${board.name}`}
23+
maw="min(42vw, 20rem)"
24+
mih={44}
25+
px={4}
26+
>
27+
<Group gap="xs" wrap="nowrap">
28+
<BoardLogo size={28} />
29+
<Text fw={600} truncate>
30+
{board.name}
31+
</Text>
32+
<IconChevronDown size={16} aria-hidden />
33+
</Group>
34+
</UnstyledButton>
35+
</Menu.Target>
36+
<Menu.Dropdown>
37+
<Menu.Label>{t("board.mobile.currentBoard")}</Menu.Label>
38+
<ScrollArea.Autosize mah={320}>
39+
{boards.map((availableBoard) => {
40+
const isCurrent = availableBoard.id === board.id;
41+
return (
42+
<Menu.Item
43+
key={availableBoard.id}
44+
component={Link}
45+
href={`/boards/${availableBoard.name}`}
46+
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
47+
aria-current={isCurrent ? "page" : undefined}
48+
>
49+
{availableBoard.name}
50+
</Menu.Item>
51+
);
52+
})}
53+
</ScrollArea.Autosize>
54+
</Menu.Dropdown>
55+
</Menu>
56+
);
57+
};

apps/nextjs/src/app/[locale]/boards/_header-actions.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
import { IconLayoutBoard } from "@tabler/icons-react";
44

55
import { useRequiredBoard } from "@homarr/boards/context";
6+
import { useI18n } from "@homarr/translation/client";
67

78
import { HeaderButton } from "~/components/layout/header/button";
89

910
export const BoardOtherHeaderActions = () => {
1011
const board = useRequiredBoard();
12+
const t = useI18n();
1113

1214
return (
13-
<HeaderButton href={`/boards/${board.name}`}>
15+
<HeaderButton href={`/boards/${board.name}`} aria-label={t("common.userAvatar.menu.homeBoard")}>
1416
<IconLayoutBoard stroke={1.5} />
1517
</HeaderButton>
1618
);

0 commit comments

Comments
 (0)