diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 1e4e47a4f..d8724f01e 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -83,9 +83,13 @@ Changing the column count automatically adjusts item positions and sizes to fit #### Mobile layout -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. +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. -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. +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. + +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. + +To rearrange, add, remove, resize, or configure board items, open the board on a larger screen. ### Background #### Background image URL diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 16be5c478..82dce0ba3 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -44,6 +44,7 @@ export const createBoardContentPage = >( headerActions: , getInitialBoardAsync: getInitialBoard, withTour: true, + mobileProfileInActions: true, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 822b83989..3f5a63345 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,10 +1,10 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; -import { Box, Group, Menu, ScrollArea } from "@mantine/core"; -import { useHotkeys } from "@mantine/hooks"; +import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core"; +import { useDisclosure, useHotkeys, useReducedMotion } from "@mantine/hooks"; import { IconBox, IconBoxAlignTop, @@ -17,11 +17,18 @@ import { IconReplace, IconResize, IconSettings, + IconDotsVertical, + IconHome, + IconLogin, + IconList, + IconLogout, + IconTool, + IconUser, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; -import { useRequiredBoard } from "@homarr/boards/context"; +import { signOut, useSession } from "@homarr/auth/client"; +import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; @@ -32,14 +39,18 @@ 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 }) => { @@ -47,6 +58,11 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool const board = useRequiredBoard(); const { hasChangeAccess } = useBoardPermissions(board); const isMobile = useIsMobileBoard(); + const t = useI18n(); + + if (isMobile) { + return ; + } if (!hasChangeAccess) { return ; @@ -60,7 +76,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool {!demoReadOnly && ( - + @@ -125,7 +141,7 @@ const AddMenu = () => { return ( - + @@ -164,6 +180,7 @@ 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({ @@ -203,7 +220,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: return ( - + {isEditMode ? : } @@ -212,13 +229,14 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: const SelectBoardsMenu = () => { const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + const t = useI18n(); return ( - + @@ -242,6 +260,145 @@ 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 ( + <> + + + + + + + + + {session ? ( + <> + + + + + ) : ( + + )} + {showSettings && ( + + )} + {sections.length >= 2 && ( + <> + {showSettings && } + + + + {t("board.mobile.sections")} + + + {sections.map((section) => ( + + ))} + + )} + + + + ); +}; + const anchorSelector = "a[href]:not([target='_blank'])"; const usePreventLeaveWithDirty = (isDirty: boolean) => { const t = useI18n(); diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx new file mode 100644 index 000000000..f28a04489 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -0,0 +1,57 @@ +"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 ( + + + + + + + {board.name} + + + + + + + {t("board.mobile.currentBoard")} + + {boards.map((availableBoard) => { + const isCurrent = availableBoard.id === board.id; + return ( + : } + aria-current={isCurrent ? "page" : undefined} + > + {availableBoard.name} + + ); + })} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx index c68be29e9..589fdf7b7 100644 --- a/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx @@ -3,14 +3,16 @@ 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 ( - + ); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 124fef581..8bcbec028 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -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,6 +21,7 @@ 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" }); @@ -33,12 +34,14 @@ interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; withTour?: boolean; + mobileProfileInActions?: boolean; } export const createBoardLayout = ({ headerActions, getInitialBoardAsync: getInitialBoard, withTour = false, + mobileProfileInActions = false, }: CreateBoardLayoutProps) => { const Layout = async ({ params, @@ -65,23 +68,36 @@ export const createBoardLayout = ({ throw error; }); const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]); - const initialIsMobile = userAgent(new Headers(requestHeaders)).device.type === "mobile"; + const deviceType = userAgent(new Headers(requestHeaders)).device.type; + const initialDeviceClass: MobileBoardDeviceClass = + deviceType === "mobile" ? "phone" : deviceType === "tablet" ? "tablet" : "desktop"; return ( - + - + } + logo={} + logoHref={null} actions={headerActions} hasNavigation={false} + withSafeArea + hideUserOnMobileBoard={mobileProfileInActions} /> - {children} + + {children} + diff --git a/apps/nextjs/src/app/[locale]/boards/layout.tsx b/apps/nextjs/src/app/[locale]/boards/layout.tsx new file mode 100644 index 000000000..04e3a3e4d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/layout.tsx @@ -0,0 +1,10 @@ +import type { PropsWithChildren } from "react"; +import type { Viewport } from "next"; + +export const viewport: Viewport = { + viewportFit: "cover", +}; + +export default function BoardsLayout({ children }: PropsWithChildren) { + return children; +} diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index d869538e4..3499184c8 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -11,6 +11,7 @@ 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"; @@ -22,6 +23,9 @@ import { WidgetContextMenu } from "./widget-context-menu"; interface BoardItemContentProps { item: SectionItem; + displayMode?: WidgetDisplayMode; + disableContextMenu?: boolean; + widgetStateRef?: MutableRefObject | null>; } const getOverflowFromKind = (kind: SectionItem["kind"]) => { @@ -30,36 +34,55 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; -export const BoardItemContent = ({ item }: BoardItemContentProps) => { +export const BoardItemContent = ({ + item, + displayMode = "default", + disableContextMenu = false, + widgetStateRef: externalWidgetStateRef, +}: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); - const widgetStateRef = useRef | null>(null); + const internalWidgetStateRef = useRef | null>(null); + const widgetStateRef = externalWidgetStateRef ?? internalWidgetStateRef; + const content = ( + + + + ); return ( <> - - - - - + {disableContextMenu ? ( + content + ) : ( + + {content} + + )} {item.advancedOptions.title?.trim() && ( | null>; } -const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); @@ -145,6 +169,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { }, }) } + displayMode={displayMode} {...dimensions} /> diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx new file mode 100644 index 000000000..ea142a886 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -0,0 +1,43 @@ +"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(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 ( +
+ {hasMounted ? children : } +
+ ); +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index 902e77372..fae316cc7 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -1,20 +1,61 @@ +.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) / 2), 12rem); + grid-auto-rows: min(calc((100vw - 42px - env(safe-area-inset-left) - env(safe-area-inset-right)) / 2), 12rem); gap: 10px; - padding-bottom: 10px; } .item { position: relative; min-width: 0; min-height: 0; + content-visibility: auto; + contain-intrinsic-size: auto 12rem; } -.item > :global(.grid-stack-item-content) { +.deferredItem { width: 100%; height: 100%; - overflow-y: auto; +} + +.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; } diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index aa06a521f..918ea6217 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -2,21 +2,98 @@ import type { CSSProperties, RefObject } from "react"; import { useMemo, useRef } from "react"; -import { Box } from "@mantine/core"; +import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconLayoutBoard } from "@tabler/icons-react"; 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 { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout"; +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 | null>(null); + const presentation = getMobilePresentation(item); + + return ( + + + + + + + ); +}; 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(null); const itemRefs = useRef>>({}); @@ -32,21 +109,47 @@ export const MobileBoard = () => { refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef }, }} > - - {items.map((item) => ( - - - - ))} + + {elements.length === 0 ? ( +
+ + + + + + {t("board.mobile.empty.title")} + + + {t("board.mobile.empty.description")} + + +
+ ) : ( + renderBlocks.map((block) => + block.type === "sectionHeading" ? ( + + {block.title} + + ) : ( + + {block.items.map((item) => ( + + ))} + + ), + ) + )}
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index 3e06594d0..f617694d5 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -5,7 +5,12 @@ 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 { createMobileBoardItems, getMobileRootSection } from "./mobile-layout"; +import { + createMobileBoardElements, + createMobileBoardItems, + getMobileRootSection, + getMobileSectionAnchorId, +} from "./mobile-layout"; describe("createMobileBoardItems", () => { test("selects the first root section in visual order", () => { @@ -93,4 +98,86 @@ 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"]); + }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index ab8bd4d65..1017856a4 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -1,7 +1,8 @@ -import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types"; +import type { Board, CategorySection, 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 = { @@ -12,6 +13,16 @@ 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; @@ -28,6 +39,8 @@ export const getMobileRootSection = (board: Board) => yOffset: 0, }; +export const getMobileSectionAnchorId = (sectionId: string) => `${mobileSectionAnchorPrefix}${sectionId}`; + const getLayout = ( layouts: TLayout[], desktopLayoutId: string, @@ -38,7 +51,20 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => { +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[] => { const rootSections = getMobileRootSections(board); const defaultSectionId = rootSections[0]?.id ?? "mobile"; const layoutPriority = new Map( @@ -80,38 +106,66 @@ export const createMobileBoardItems = (board: Board, desktopLayoutId: string): S elementsBySection.set(parentSectionId, elements); } - const result: SectionItem[] = []; + const result: MobileBoardElement[] = []; const visitedItems = new Set(); const visitedSections = new Set(); - const appendSectionItems = (sectionId: string) => { - if (visitedSections.has(sectionId)) return; + const createSectionElements = ( + section: CategorySection | DynamicSectionItem | (typeof rootSections)[number], + ): { elements: MobileBoardElement[]; items: SectionItem[] } => { + if (visitedSections.has(section.id)) return { elements: [], items: [] }; + const sectionId = section.id; visitedSections.add(sectionId); + const sectionElements: MobileBoardElement[] = []; + const sectionItems: SectionItem[] = []; for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) { if (element.type === "section") { - appendSectionItems(element.id); + const nestedResult = createSectionElements(element); + sectionElements.push(...nestedResult.elements); + sectionItems.push(...nestedResult.items); continue; } if (!visitedItems.has(element.id)) { visitedItems.add(element.id); - result.push(element); + sectionElements.push(element); + sectionItems.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) => appendSectionItems(section.id)); - dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id)); + rootSections.forEach((section) => result.push(...createSectionElements(section).elements)); + dynamicSections + .toSorted(comparePosition) + .forEach((section) => result.push(...createSectionElements(section).elements)); items.toSorted(comparePosition).forEach((item) => { if (!visitedItems.has(item.id)) result.push(item); }); - 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)), - })); + 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)), + }; + }); }; + +export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => + createMobileBoardElements(board, desktopLayoutId).filter( + (element): element is SectionItem => element.type === "item", + ); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts new file mode 100644 index 000000000..c2e6c1197 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -0,0 +1,44 @@ +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, + }); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts new file mode 100644 index 000000000..62dbd65aa --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -0,0 +1,13 @@ +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"), +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx new file mode 100644 index 000000000..d05b1ebcf --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -0,0 +1,166 @@ +"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 | 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, + [item.kind, item.options, settings], + ); + const contextActions = useMemo(() => { + if (!("contextActions" in definition) || typeof definition.contextActions !== "function") return []; + + return definition.contextActions({ + options, + setOptions: (partial: Record) => + 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 ( + <> + + + + + + + {supportsDetails && ( + + )} + {visibleContextActions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} + + + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/board/use-mobile-board.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts new file mode 100644 index 000000000..35a8a95b6 --- /dev/null +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -0,0 +1,58 @@ +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, + }); + }); +}); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 84eea8508..7197aac22 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -1,21 +1,97 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; +import { Box, Group, Skeleton, Stack } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; export const mobileBoardMediaQuery = "(max-width: 48em)"; -const InitialMobileBoardContext = createContext(undefined); +export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop"; -export const MobileBoardViewportProvider = ({ - initialIsMobile, - children, -}: PropsWithChildren<{ initialIsMobile: boolean }>) => ( - {children} +interface MobileBoardViewport { + deviceClass: MobileBoardDeviceClass; + isMobile: boolean; + isResolved: boolean; +} + +const MobileBoardViewportContext = createContext({ + 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 = () => ( + + + + + + + + + + + + + + + + + + + + ); -export const useIsMobileBoard = () => { - const initialIsMobile = useContext(InitialMobileBoardContext); - return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true }); +export const MobileBoardViewportProvider = ({ + initialDeviceClass, + children, +}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { + const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); + const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { + getInitialValueInEffect: true, + }); + + useEffect(() => { + setHasResolvedClientWidth(true); + }, []); + + const viewport = resolveMobileBoardViewport({ + deviceClass: initialDeviceClass, + matchesMobileWidth, + hasResolvedClientWidth, + }); + + return ( + + {viewport.isResolved ? children : } + + ); }; + +export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext); + +export const useIsMobileBoard = () => useMobileBoardViewport().isMobile; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index e9d93f7fc..7007f3ea8 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -6,25 +6,49 @@ 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, actions, hasNavigation = true }: Props) => { +export const MainHeader = ({ + logo, + logoHref = "/", + actions, + hasNavigation = true, + withSafeArea = false, + hideUserOnMobileBoard = false, +}: Props) => { return ( - - + + {hasNavigation && } - - {logo ?? } - + {logoHref === null ? ( + (logo ?? ) + ) : ( + + {logo ?? } + + )} @@ -32,9 +56,11 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { {actions} - - - + + + + + diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index e8d858b6a..d28d46fba 100644 --- a/apps/nextjs/src/components/layout/header/button.tsx +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -20,7 +20,7 @@ const headerButtonActionIconProps: ActionIconProps = { variant: "subtle", style: { border: "none" }, color: "gray", - size: "lg", + size: 44, }; export const HeaderButton = forwardRef((props, ref) => { @@ -29,15 +29,15 @@ export const HeaderButton = forwardRef((pr } component={Link} - {...props} {...headerButtonActionIconProps} + {...props} > {props.children} ); } return ( - + {props.children} ); diff --git a/apps/nextjs/src/components/layout/header/responsive-user.tsx b/apps/nextjs/src/components/layout/header/responsive-user.tsx new file mode 100644 index 000000000..b97c0383e --- /dev/null +++ b/apps/nextjs/src/components/layout/header/responsive-user.tsx @@ -0,0 +1,13 @@ +"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; +}; diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index 8f6e4f908..95ebcf9f0 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -28,8 +28,10 @@ export const DesktopSearchInput = () => { }; export const MobileSearchButton = () => { + const t = useI18n(); + return ( - + ); diff --git a/apps/nextjs/src/components/layout/header/user.tsx b/apps/nextjs/src/components/layout/header/user.tsx index af3d72121..8ca11a043 100644 --- a/apps/nextjs/src/components/layout/header/user.tsx +++ b/apps/nextjs/src/components/layout/header/user.tsx @@ -4,19 +4,20 @@ 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 = await auth(); + const [session, t] = await Promise.all([auth(), getI18n()]); const isAdmin = session?.user.permissions.includes("admin"); const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined; return ( - + }> diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index b51cf3220..40e84e232 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -10,11 +10,13 @@ import { navigationCollapsedAtom } from "./header/burger"; interface ClientShellProps { hasHeader?: boolean; hasNavigation?: boolean; + withSafeArea?: boolean; } export const ClientShell = ({ hasHeader = true, hasNavigation = true, + withSafeArea = false, children, }: PropsWithChildren) => { const collapsed = useAtomValue(navigationCollapsedAtom); @@ -23,7 +25,7 @@ export const ClientShell = ({ return ( ({ diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 6d309ce42..84b8650a0 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useParams } from "next/navigation"; -import { useMantineTheme } from "@mantine/core"; +import { Center, Stack, Text, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; import dayjs from "dayjs"; @@ -11,6 +11,7 @@ import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { CalendarDay } from "./calender-day"; @@ -31,7 +32,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> { setMonth: (date: Date) => void; } -const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => { +const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => { const input = { integrationIds, month: month.getMonth(), @@ -43,7 +44,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); - return ; + return ( + + ); }; interface CalendarBaseProps { @@ -52,13 +62,15 @@ interface CalendarBaseProps { month: Date; setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; + displayMode?: WidgetComponentProps<"calendar">["displayMode"]; } -const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => { +const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => { const params = useParams(); const locale = params.locale as string; const { firstDayOfWeek } = useSettings(); const board = useRequiredBoard(); + const t = useScopedI18n("widget.calendar"); const mantineTheme = useMantineTheme(); const actualItemRadius = mantineTheme.radius[board.itemRadius]; const { ref, width, height } = useElementSize(); @@ -66,6 +78,38 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar const normalizedEvents = useMemo(() => splitEvents(events), [events]); + if (displayMode === "mobileSummary") { + const nextEvent = normalizedEvents + .filter((event) => event.startDate.getTime() >= Date.now()) + .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime())[0]; + const link = nextEvent?.links[0]; + + return ( +
+ + + {nextEvent?.title ?? normalizedEvents.length} + + + {nextEvent + ? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(nextEvent.startDate) + : t("name")} + + +
+ ); + } + return ( ({ releaseType: factory.multiSelect({ diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 20b22d6a0..5cf9d8c54 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -6,6 +6,11 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + }, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/common/mobile-summary.tsx b/packages/widgets/src/common/mobile-summary.tsx new file mode 100644 index 000000000..d3f6e9bf1 --- /dev/null +++ b/packages/widgets/src/common/mobile-summary.tsx @@ -0,0 +1,29 @@ +import { Center, Group, Stack, Text } from "@mantine/core"; + +interface WidgetMobileSummaryProps { + value: number | string; + label: string; + description?: string; +} + +export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) { + return ( +
+ + + + {value} + + + {label} + + + {description !== undefined && ( + + {description} + + )} + +
+ ); +} diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 3ba53987e..b5900267d 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -12,6 +12,16 @@ import type { TablerIcon } from "@homarr/ui"; import type { WidgetImports } from "."; import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; +export type WidgetDisplayMode = "default" | "mobileSummary" | "mobileDetail"; + +export interface WidgetMobilePresentation { + width: 1 | 2; + height: 1 | 2 | 3; + supportsCompactSummary?: boolean; + supportsDetailView?: boolean; + eager?: boolean; +} + export interface WidgetContextMenuAction { key: string; label: stringOrTranslation; @@ -69,6 +79,7 @@ export const createWidgetDefinition = { export type WidgetComponentProps = WidgetProps & { boardId: string | undefined; // undefined when in preview mode + displayMode?: WidgetDisplayMode; isEditMode: boolean; setOptions: ({ newOptions }: { newOptions: Partial>> }) => void; width: number; diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index ff9b71664..5ca7ab5b6 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -83,6 +83,7 @@ export default function DownloadClientsWidget({ integrationIds, options, setOptions, + displayMode, }: WidgetComponentProps<"downloads">) { const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => integrationIds.includes(id) ? [id] : [], @@ -605,6 +606,23 @@ export default function DownloadClientsWidget({ { up: 0, down: 0 }, ); + if (displayMode === "mobileSummary") { + const totalDownloadSpeed = clients.reduce((total, { status }) => total + (status?.rates.down ?? 0), 0); + + return ( +
+ + + {data.length} + + + {`${t("items.downSpeed.columnTitle")}: ${formatByteRate(totalDownloadSpeed)}`} + + +
+ ); + } + if (options.columns.length === 0) return (
diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 0d9c66ddd..7c876b926 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -33,6 +33,12 @@ const columnsSort = columnsList.filter((column) => export const { definition, componentLoader } = createWidgetDefinition("downloads", { icon: IconDownload, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, refetchInterval: 5, createOptions() { return optionsBuilder.from( diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 061fc4b2d..0dde96d39 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -67,6 +67,8 @@ import * as customApi from "./custom-api"; export type { WidgetDefinition, + WidgetDisplayMode, + WidgetMobilePresentation, WidgetContextMenuAction, WidgetContextActionProps, WidgetOptionsSettings, diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index 69c1edf21..d2201072e 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -1,7 +1,21 @@ "use client"; import type { ReactNode } from "react"; -import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core"; +import { + Badge, + Box, + Center, + Group, + Image, + Paper, + RingProgress, + ScrollArea, + SimpleGrid, + Stack, + Tabs, + Text, + ThemeIcon, +} from "@mantine/core"; import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -9,11 +23,18 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; +import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; -export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) { +export default function MediaMissingWidget({ + integrationIds, + options, + width, + height, + displayMode, +}: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const pageSize = Number(options.pageSize); const { data } = clientApi.widget.mediaOrganizer.getData.useQuery( @@ -32,25 +53,32 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
); - const missing = data.flatMap((entry) => - entry.missing.map((item) => ({ item, integrationId: entry.integrationId })), - ); - const queued = data.flatMap((entry) => - entry.queued.map((item) => ({ item, integrationId: entry.integrationId })), - ); + const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId }))); + const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId }))); const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0); const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0); + if (displayMode === "mobileSummary") { + const primary = options.showMissing + ? { value: missingCount, label: t("tab.missing") } + : { value: queuedCount, label: t("tab.queued") }; + const description = options.showMissing && options.showQueued ? `${queuedCount} ${t("tab.queued")}` : undefined; + + return ; + } + const isThin = width > 0 && width < 160; const isShort = height > 0 && height < 180; const targetCardWidth = isShort ? 130 : 200; const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1; const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable"; - const tabLabel = (label: string, shown: number, total: number) => - isThin ? total : `${label} (${shown}/${total})`; + const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`); - const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => ( + const renderPanel = ( + entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], + emptyLabel: string, + ) => ( {entries.length === 0 ? ( @@ -116,7 +144,14 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | " } return ( - + {type === "movie" ? : } ); @@ -183,8 +218,25 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density ); }; -const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => ( - +const CardShell = ({ + item, + density, + children, +}: { + item: MissingMediaItem | QueuedMediaItem; + density: Density; + children: ReactNode; +}) => ( + {item.imageUrl && ( )} diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts index eaa44be6d..98209d43c 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -9,6 +9,12 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", { icon: IconMovie, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, createOptions() { return optionsBuilder.from((factory) => ({ showMissing: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 05ba18fe5..4f243c77a 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -16,11 +16,21 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; +import { WidgetMobileSummary } from "../common/mobile-summary"; -export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) { +export default function MediaReleasesWidget({ + options, + integrationIds, + displayMode, +}: WidgetComponentProps<"mediaReleases">) { const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds, }); + const t = useI18n("widget.mediaReleases"); + + if (displayMode === "mobileSummary") { + return ; + } return ( diff --git a/packages/widgets/src/media-releases/index.ts b/packages/widgets/src/media-releases/index.ts index 3e6cccbe5..96273e486 100644 --- a/packages/widgets/src/media-releases/index.ts +++ b/packages/widgets/src/media-releases/index.ts @@ -5,6 +5,12 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", { icon: IconTicket, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, queryKey: [["widget", "mediaRelease"]], refetchInterval: null, createOptions() { diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index c5dc5bf06..865a35123 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -26,6 +26,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; +import { WidgetMobileSummary } from "../../common/mobile-summary"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "../search-button.module.css"; @@ -35,7 +36,9 @@ export default function MediaServerWidget({ isEditMode, options, width, + displayMode, }: WidgetComponentProps<"mediaRequests-requestList">) { + const t = useScopedI18n("widget.mediaRequests-requestList"); const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ integrationIds, statuses: @@ -48,6 +51,10 @@ export default function MediaServerWidget({ if (!mediaRequests) return ; if (mediaRequests.length === 0) throw new NoIntegrationDataError(); + if (displayMode === "mobileSummary") { + return ; + } + return ( {!isEditMode && } diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index 1f65cd0c3..c8824bebc 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -11,6 +11,12 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", " export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { icon: IconZoomQuestion, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, createOptions() { return optionsBuilder.from((factory) => ({ linksTargetNewTab: factory.switch({ diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index fe4873e45..51962c00b 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -10,6 +10,7 @@ import type { TablerIcon } from "@homarr/ui"; import { views } from "."; import { WidgetEmptyState } from "../common/empty-state"; +import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; @@ -30,6 +31,7 @@ export default function MediaTranscodingWidget({ integrationIds, options, width, + displayMode, }: WidgetComponentProps<"mediaTranscoding">) { const [queuePage, setQueuePage] = useState(1); const queuePageSize = 10; @@ -45,6 +47,27 @@ export default function MediaTranscodingWidget({ if (!transcodingData) return ; + if (displayMode === "mobileSummary") { + const summary = + options.defaultView === "workers" + ? { value: transcodingData.data.workers.length, label: t("tab.workers") } + : options.defaultView === "queue" + ? { value: transcodingData.data.queue.totalCount, label: t("tab.queue") } + : { + value: transcodingData.data.statistics.totalTranscodeCount, + label: t("panel.statistics.transcodesCount"), + }; + const failedHealthChecks = transcodingData.data.statistics.failedHealthCheckCount; + + return ( + 0 ? "healthCheck.status.unhealthy" : "healthCheck.status.healthy")} + /> + ); + } + const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize); const isTiny = width < 256; diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts index ff4922dad..95f0f0388 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -11,6 +11,12 @@ export const views = ["workers", "queue", "statistics"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { icon: IconTransform, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index 3b04393d0..bcdda7f93 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; +import { Avatar, Box, Card, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; import { IconClock } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -11,7 +11,11 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) { +export default function NotificationsWidget({ + options, + integrationIds, + displayMode, +}: WidgetComponentProps<"notifications">) { const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, @@ -29,6 +33,35 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC [notificationIntegrations], ); + if (displayMode === "mobileSummary") { + const latestNotification = sortedNotifications[0]; + + return ( +
+ {latestNotification ? ( + latestNotification.href ? ( + + + + ) : ( + + ) + ) : ( + + {t("noItems")} + + )} +
+ ); + } + return ( @@ -80,6 +113,23 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC ); } +const NotificationSummary = ({ + notification, +}: { + notification: { + title: string | null; + body: string; + time: Date; + }; +}) => ( + + + {notification.title ?? notification.body} + + + +); + const InfoDisplay = ({ date }: { date: Date }) => { const timeAgo = useTimeAgo(date, 30000); // update every 30sec diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 17fd13393..f7f34fff9 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -7,6 +7,12 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("notifications", { icon: IconMessage, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts index a4dd32921..edb46311c 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -7,6 +7,12 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", { icon: IconBinaryTree, + mobile: { + width: 1, + height: 1, + supportsCompactSummary: true, + eager: true, + }, createOptions() { return optionsBuilder.from((factory) => ({ entityId: factory.text({ diff --git a/packages/widgets/src/smart-home/execute-automation/index.ts b/packages/widgets/src/smart-home/execute-automation/index.ts index ea957fab9..0625129fd 100644 --- a/packages/widgets/src/smart-home/execute-automation/index.ts +++ b/packages/widgets/src/smart-home/execute-automation/index.ts @@ -7,6 +7,12 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", { icon: IconBinaryTree, + mobile: { + width: 1, + height: 1, + supportsCompactSummary: true, + eager: true, + }, createOptions() { return optionsBuilder.from((factory) => ({ displayName: factory.text(),