11"use client" ;
22
3- import { useCallback , useEffect } from "react" ;
3+ import { useCallback , useEffect , useMemo } from "react" ;
44import { useRouter } from "next/navigation" ;
55import { 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" ;
88import {
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
2229import { 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" ;
2532import { useEditMode } from "@homarr/boards/edit-mode" ;
2633import { revalidatePathActionAsync } from "@homarr/common/client" ;
2734import { env } from "@homarr/common/env" ;
@@ -32,21 +39,30 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
3239import { useI18n , useScopedI18n } from "@homarr/translation/client" ;
3340import { Link } from "@homarr/ui" ;
3441
42+ import { useAuthContext } from "~/app/[locale]/_client-providers/session" ;
3543import { useItemActions } from "~/components/board/items/item-actions" ;
3644import { ItemSelectModal } from "~/components/board/items/item-select-modal" ;
3745import { useBoardPermissions } from "~/components/board/permissions/client" ;
3846import { useCategoryActions } from "~/components/board/sections/category/category-actions" ;
3947import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal" ;
4048import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions" ;
49+ import { createMobileBoardElements } from "~/components/board/mobile/mobile-layout" ;
4150import { useIsMobileBoard } from "~/components/board/use-mobile-board" ;
4251import { 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" ;
4354import { HeaderButton } from "~/components/layout/header/button" ;
4455
4556export 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
213230const 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+
245402const anchorSelector = "a[href]:not([target='_blank'])" ;
246403const usePreventLeaveWithDirty = ( isDirty : boolean ) => {
247404 const t = useI18n ( ) ;
0 commit comments