diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index d8724f01e..1e4e47a4f 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -83,13 +83,9 @@ Changing the column count automatically adjusts item positions and sizes to fit #### Mobile layout -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. +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. -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. +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. ### 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 82dce0ba3..16be5c478 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -44,7 +44,6 @@ 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 3f5a63345..822b83989 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, useMemo } from "react"; +import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; -import { Box, Button, Divider, Drawer, Group, Menu, ScrollArea, Stack, Text } from "@mantine/core"; -import { useDisclosure, useHotkeys, useReducedMotion } from "@mantine/hooks"; +import { Box, Group, Menu, ScrollArea } from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; import { IconBox, IconBoxAlignTop, @@ -17,18 +17,11 @@ import { IconReplace, IconResize, IconSettings, - IconDotsVertical, - IconHome, - IconLogin, - IconList, - IconLogout, - IconTool, - IconUser, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { signOut, useSession } from "@homarr/auth/client"; -import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useSession } from "@homarr/auth/client"; +import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; @@ -39,18 +32,14 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useAuthContext } from "~/app/[locale]/_client-providers/session"; import { useItemActions } from "~/components/board/items/item-actions"; import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; -import { createMobileBoardElements } from "~/components/board/mobile/mobile-layout"; import { useIsMobileBoard } from "~/components/board/use-mobile-board"; import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; -import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox"; -import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { HeaderButton } from "~/components/layout/header/button"; export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { @@ -58,11 +47,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool const board = useRequiredBoard(); const { hasChangeAccess } = useBoardPermissions(board); const isMobile = useIsMobileBoard(); - const t = useI18n(); - - if (isMobile) { - return ; - } if (!hasChangeAccess) { return ; @@ -76,7 +60,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool {!demoReadOnly && ( - + @@ -141,7 +125,7 @@ const AddMenu = () => { return ( - + @@ -180,7 +164,6 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: const board = useRequiredBoard(); const utils = clientApi.useUtils(); const t = useScopedI18n("board.action.edit"); - const tItem = useScopedI18n("item"); const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({ onSuccess() { showSuccessNotification({ @@ -220,7 +203,7 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: return ( - + {isEditMode ? : } @@ -229,14 +212,13 @@ const EditModeMenu = ({ demoReadOnly, hidden }: { demoReadOnly: boolean; hidden: const SelectBoardsMenu = () => { const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); - const t = useI18n(); return ( - + @@ -260,145 +242,6 @@ const SelectBoardsMenu = () => { ); }; -const MobileMoreMenu = ({ showSettings }: { showSettings: boolean }) => { - const board = useRequiredBoard(); - const { data: session } = useSession(); - const { logoutUrl } = useAuthContext(); - const t = useI18n(); - const tProfile = useScopedI18n("common.userAvatar.menu"); - const reduceMotion = useReducedMotion(); - const [opened, disclosure] = useDisclosure(false); - const desktopLayout = getDesktopLayout(board); - const sections = useMemo( - () => createMobileBoardElements(board, desktopLayout.id).filter((element) => element.type === "sectionHeading"), - [board, desktopLayout.id], - ); - - const jumpToSection = (anchorId: string) => { - disclosure.close(); - requestAnimationFrame(() => { - const heading = document.getElementById(anchorId); - heading?.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); - heading?.focus({ preventScroll: true }); - }); - }; - - return ( - <> - - - - - - - - - {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 deleted file mode 100644 index f28a04489..000000000 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { Group, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core"; -import { IconCheck, IconChevronDown, IconLayoutBoard } from "@tabler/icons-react"; - -import { clientApi } from "@homarr/api/client"; -import { useRequiredBoard } from "@homarr/boards/context"; -import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; - -import { BoardLogo } from "~/components/layout/logo/board-logo"; - -export const BoardSwitcherLogo = () => { - const board = useRequiredBoard(); - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); - const t = useI18n(); - - return ( - - - - - - - {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 589fdf7b7..c68be29e9 100644 --- a/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx @@ -3,16 +3,14 @@ import { IconLayoutBoard } from "@tabler/icons-react"; import { useRequiredBoard } from "@homarr/boards/context"; -import { useI18n } from "@homarr/translation/client"; import { HeaderButton } from "~/components/layout/header/button"; export const BoardOtherHeaderActions = () => { const board = useRequiredBoard(); - const t = useI18n(); return ( - + ); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 8bcbec028..124fef581 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,7 +21,6 @@ import type { Params } from "./(content)/_creator"; import { CustomCss } from "./(content)/_custom-css"; import { BoardReadyProvider } from "./(content)/_ready-context"; import { BoardMantineProvider } from "./(content)/_theme"; -import { BoardSwitcherLogo } from "./_board-switcher-logo"; const logger = createLogger({ module: "createBoardLayout" }); @@ -34,14 +33,12 @@ interface CreateBoardLayoutProps { 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, @@ -68,36 +65,23 @@ export const createBoardLayout = ({ throw error; }); const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]); - const deviceType = userAgent(new Headers(requestHeaders)).device.type; - const initialDeviceClass: MobileBoardDeviceClass = - deviceType === "mobile" ? "phone" : deviceType === "tablet" ? "tablet" : "desktop"; + const initialIsMobile = userAgent(new Headers(requestHeaders)).device.type === "mobile"; return ( - + - + } - logoHref={null} + logo={} 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 deleted file mode 100644 index 04e3a3e4d..000000000 --- a/apps/nextjs/src/app/[locale]/boards/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { PropsWithChildren } from "react"; -import type { Viewport } from "next"; - -export const viewport: Viewport = { - viewportFit: "cover", -}; - -export default function BoardsLayout({ children }: PropsWithChildren) { - return children; -} diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index c41f23356..ad0f3a305 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -11,7 +11,6 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; -import type { WidgetDisplayMode } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; import type { SectionItem } from "~/app/[locale]/boards/_types"; @@ -24,9 +23,6 @@ import { removePersistedWidgetQueries } from "./widget-query-recovery"; interface BoardItemContentProps { item: SectionItem; - displayMode?: WidgetDisplayMode; - disableContextMenu?: boolean; - widgetStateRef?: MutableRefObject | null>; } const getOverflowFromKind = (kind: SectionItem["kind"]) => { @@ -35,55 +31,36 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { return undefined; }; -export const BoardItemContent = ({ - item, - displayMode = "default", - disableContextMenu = false, - widgetStateRef: externalWidgetStateRef, -}: BoardItemContentProps) => { +export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); - const internalWidgetStateRef = useRef | null>(null); - const widgetStateRef = externalWidgetStateRef ?? internalWidgetStateRef; - const content = ( - - - - ); + const widgetStateRef = useRef | null>(null); return ( <> - {disableContextMenu ? ( - content - ) : ( - - {content} - - )} + + + + + {item.advancedOptions.title?.trim() && ( | null>; } -const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) => { +const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); @@ -170,7 +146,6 @@ const InnerContent = ({ item, displayMode, ...dimensions }: InnerContentProps) = }, }) } - displayMode={displayMode} {...dimensions} /> diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx deleted file mode 100644 index ea142a886..000000000 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import type { PropsWithChildren } from "react"; -import { useEffect, useRef, useState } from "react"; -import { Skeleton } from "@mantine/core"; - -import classes from "./mobile-board.module.css"; - -interface DeferredMobileItemProps extends PropsWithChildren { - eager: boolean; -} - -export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => { - const [hasMounted, setHasMounted] = useState(eager); - const wrapperRef = useRef(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 fae316cc7..902e77372 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -1,61 +1,20 @@ -.stream { - display: flex; - flex-direction: column; - gap: 10px; - padding-bottom: 10px; - overflow-x: clip; -} - .grid { display: grid; grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); grid-auto-flow: row; - grid-auto-rows: min(calc((100vw - 42px - env(safe-area-inset-left) - env(safe-area-inset-right)) / 2), 12rem); + grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem); gap: 10px; + padding-bottom: 10px; } .item { position: relative; min-width: 0; min-height: 0; - content-visibility: auto; - contain-intrinsic-size: auto 12rem; } -.deferredItem { +.item > :global(.grid-stack-item-content) { width: 100%; height: 100%; -} - -.deferredItem > :global(.grid-stack-item-content) { - width: 100%; - height: 100%; - overflow: hidden; -} - -.sectionHeading { - scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md)); - padding-block: var(--mantine-spacing-xs); - outline-offset: 4px; -} - -.empty { - min-height: min(60dvh, 32rem); - padding: var(--mantine-spacing-xl); -} - -.widgetAction { - position: absolute; - z-index: 11; - top: 4px; - right: 4px; - border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent); - box-shadow: var(--mantine-shadow-sm); -} - -.detailContent, -.detailContent > :global(.grid-stack-item-content) { - width: 100%; - height: 100%; - min-height: 24rem; + overflow-y: auto; } diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 918ea6217..aa06a521f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -2,98 +2,21 @@ import type { CSSProperties, RefObject } from "react"; import { useMemo, useRef } from "react"; -import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; -import { IconLayoutBoard } from "@tabler/icons-react"; +import { Box } from "@mantine/core"; import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode"; import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack"; -import { useI18n } from "@homarr/translation/client"; -import { widgetImports } from "@homarr/widgets"; -import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; import { SectionProvider } from "../sections/section-context"; -import { DeferredMobileItem } from "./deferred-mobile-item"; import classes from "./mobile-board.module.css"; -import { MobileWidgetActions } from "./mobile-widget-actions"; -import { - createMobileBoardElements, - createMobileBoardItems, - getMobileRootSection, - mobileColumnCount, -} from "./mobile-layout"; -import type { MobileBoardElement, MobileBoardSectionHeading } from "./mobile-layout"; -import { resolveMobileItemPresentation } from "./mobile-presentation"; - -type MobileBoardRenderBlock = - | MobileBoardSectionHeading - | { - type: "items"; - id: string; - items: SectionItem[]; - }; - -const createRenderBlocks = (elements: MobileBoardElement[]): MobileBoardRenderBlock[] => { - const blocks: MobileBoardRenderBlock[] = []; - - for (const element of elements) { - if (element.type === "sectionHeading") { - blocks.push(element); - continue; - } - - const previousBlock = blocks.at(-1); - if (previousBlock?.type === "items") { - previousBlock.items.push(element); - } else { - blocks.push({ type: "items", id: `items-${element.id}`, items: [element] }); - } - } - - return blocks; -}; - -const getMobilePresentation = (item: SectionItem) => { - const definition = widgetImports[item.kind].definition; - return resolveMobileItemPresentation(item, "mobile" in definition ? definition.mobile : undefined); -}; - -const MobileBoardItem = ({ item }: { item: SectionItem }) => { - const widgetStateRef = useRef | null>(null); - const presentation = getMobilePresentation(item); - - return ( - - - - - - - ); -}; +import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout"; export const MobileBoard = () => { const board = useRequiredBoard(); - const t = useI18n(); const desktopLayout = getDesktopLayout(board); const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]); - const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]); - const renderBlocks = useMemo(() => createRenderBlocks(elements), [elements]); const rootSection = getMobileRootSection(board); const wrapperRef = useRef(null); const itemRefs = useRef>>({}); @@ -109,47 +32,21 @@ export const MobileBoard = () => { refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef }, }} > - - {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) => ( - - ))} - - ), - ) - )} + + {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 f617694d5..3e06594d0 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -5,12 +5,7 @@ import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock"; import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock"; import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock"; import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock"; -import { - createMobileBoardElements, - createMobileBoardItems, - getMobileRootSection, - getMobileSectionAnchorId, -} from "./mobile-layout"; +import { createMobileBoardItems, getMobileRootSection } from "./mobile-layout"; describe("createMobileBoardItems", () => { test("selects the first root section in visual order", () => { @@ -98,86 +93,4 @@ describe("createMobileBoardItems", () => { expect(result.map((item) => item.id)).toEqual(["second", "first"]); expect(result[1]).toMatchObject({ width: 1, height: 1 }); }); - - test("emits named category and dynamic section headings in visual order", () => { - const board = new BoardMockBuilder().build(); - const desktopLayoutId = board.layouts.at(0)?.id; - if (!desktopLayoutId) throw new Error("Expected a desktop layout"); - - const category = new CategorySectionMockBuilder({ id: "category", name: " Services ", yOffset: 0 }).build(); - const dynamicSection = new DynamicSectionMockBuilder({ - id: "dynamic", - options: { title: "Monitoring", borderColor: "", customCssClasses: [] }, - }) - .addLayout({ layoutId: desktopLayoutId, parentSectionId: category.id, xOffset: 1, yOffset: 0 }) - .build(); - const nestedDynamicSection = new DynamicSectionMockBuilder({ - id: "nested-dynamic", - options: { title: "Details", borderColor: "", customCssClasses: [] }, - }) - .addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 }) - .build(); - - board.sections.push(category, dynamicSection, nestedDynamicSection); - board.items.push( - new ItemMockBuilder({ id: "before-dynamic" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: category.id, xOffset: 0, yOffset: 0 }) - .build(), - new ItemMockBuilder({ id: "inside-dynamic" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id, xOffset: 0, yOffset: 0 }) - .build(), - new ItemMockBuilder({ id: "inside-nested" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id }) - .build(), - ); - - const result = createMobileBoardElements(board, desktopLayoutId); - - expect( - result.map((element) => (element.type === "sectionHeading" ? `heading:${element.title}` : `item:${element.id}`)), - ).toEqual([ - "heading:Services", - "item:before-dynamic", - "heading:Monitoring", - "item:inside-dynamic", - "heading:Details", - "item:inside-nested", - ]); - expect(result[0]).toMatchObject({ - type: "sectionHeading", - sectionId: category.id, - anchorId: getMobileSectionAnchorId(category.id), - }); - }); - - test("omits headings for empty and unnamed sections while preserving their items", () => { - const board = new BoardMockBuilder().build(); - const desktopLayoutId = board.layouts.at(0)?.id; - if (!desktopLayoutId) throw new Error("Expected a desktop layout"); - - const emptyNamedCategory = new CategorySectionMockBuilder({ - id: "empty-category", - name: "Empty", - yOffset: 0, - }).build(); - const unnamedCategory = new CategorySectionMockBuilder({ id: "unnamed-category", name: " ", yOffset: 1 }).build(); - const unnamedDynamicSection = new DynamicSectionMockBuilder({ id: "unnamed-dynamic" }) - .addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id }) - .build(); - - board.sections.push(emptyNamedCategory, unnamedCategory, unnamedDynamicSection); - board.items.push( - new ItemMockBuilder({ id: "inside-unnamed" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: unnamedDynamicSection.id }) - .build(), - new ItemMockBuilder({ id: "orphan" }) - .addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section" }) - .build(), - ); - - const result = createMobileBoardElements(board, desktopLayoutId); - - expect(result.map((element) => element.type)).toEqual(["item", "item"]); - expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]); - }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index 1017856a4..ab8bd4d65 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -1,8 +1,7 @@ -import type { Board, CategorySection, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types"; +import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types"; export const mobileColumnCount = 2; const mobileMaxHeight = 3; -const mobileSectionAnchorPrefix = "mobile-board-section-"; type PositionedElement = DynamicSectionItem | SectionItem; type PositionedLayout = { @@ -13,16 +12,6 @@ type PositionedLayout = { height: number; }; -export type MobileBoardSectionHeading = { - type: "sectionHeading"; - id: string; - sectionId: string; - title: string; - anchorId: string; -}; - -export type MobileBoardElement = SectionItem | MobileBoardSectionHeading; - const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) => elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset; @@ -39,8 +28,6 @@ export const getMobileRootSection = (board: Board) => yOffset: 0, }; -export const getMobileSectionAnchorId = (sectionId: string) => `${mobileSectionAnchorPrefix}${sectionId}`; - const getLayout = ( layouts: TLayout[], desktopLayoutId: string, @@ -51,20 +38,7 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => { - const title = (section.kind === "category" ? section.name : section.options.title).trim(); - if (title.length === 0) return null; - - return { - type: "sectionHeading", - id: `section-heading-${section.id}`, - sectionId: section.id, - title, - anchorId: getMobileSectionAnchorId(section.id), - }; -}; - -export const createMobileBoardElements = (board: Board, desktopLayoutId: string): MobileBoardElement[] => { +export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => { const rootSections = getMobileRootSections(board); const defaultSectionId = rootSections[0]?.id ?? "mobile"; const layoutPriority = new Map( @@ -106,66 +80,38 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) elementsBySection.set(parentSectionId, elements); } - const result: MobileBoardElement[] = []; + const result: SectionItem[] = []; const visitedItems = new Set(); const visitedSections = new Set(); - const createSectionElements = ( - section: CategorySection | DynamicSectionItem | (typeof rootSections)[number], - ): { elements: MobileBoardElement[]; items: SectionItem[] } => { - if (visitedSections.has(section.id)) return { elements: [], items: [] }; - const sectionId = section.id; + const appendSectionItems = (sectionId: string) => { + if (visitedSections.has(sectionId)) return; visitedSections.add(sectionId); - const sectionElements: MobileBoardElement[] = []; - const sectionItems: SectionItem[] = []; for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) { if (element.type === "section") { - const nestedResult = createSectionElements(element); - sectionElements.push(...nestedResult.elements); - sectionItems.push(...nestedResult.items); + appendSectionItems(element.id); continue; } if (!visitedItems.has(element.id)) { visitedItems.add(element.id); - sectionElements.push(element); - sectionItems.push(element); + result.push(element); } } - - if (sectionItems.length === 0) return { elements: [], items: [] }; - - const heading = section.kind === "empty" ? null : createSectionHeading(section); - return { - elements: heading ? [heading, ...sectionElements] : sectionElements, - items: sectionItems, - }; }; - rootSections.forEach((section) => result.push(...createSectionElements(section).elements)); - dynamicSections - .toSorted(comparePosition) - .forEach((section) => result.push(...createSectionElements(section).elements)); + rootSections.forEach((section) => appendSectionItems(section.id)); + dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id)); items.toSorted(comparePosition).forEach((item) => { if (!visitedItems.has(item.id)) result.push(item); }); - let itemIndex = 0; - return result.map((element) => { - if (element.type === "sectionHeading") return element; - - return { - ...element, - xOffset: 0, - yOffset: itemIndex++, - width: Math.max(1, Math.min(element.width, mobileColumnCount)), - height: Math.max(1, Math.min(element.height, mobileMaxHeight)), - }; - }); + return result.map((item, index) => ({ + ...item, + xOffset: 0, + yOffset: index, + width: Math.max(1, Math.min(item.width, mobileColumnCount)), + height: Math.max(1, Math.min(item.height, mobileMaxHeight)), + })); }; - -export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => - createMobileBoardElements(board, desktopLayoutId).filter( - (element): element is SectionItem => element.type === "item", - ); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts deleted file mode 100644 index c2e6c1197..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { resolveMobileItemPresentation } from "./mobile-presentation"; - -describe("resolveMobileItemPresentation", () => { - test("renders apps as eager compact tiles", () => { - expect(resolveMobileItemPresentation({ kind: "app", height: 8 }, undefined)).toMatchObject({ - width: 1, - height: 1, - eager: true, - supportsDetails: false, - }); - }); - - test("defaults widgets to full width and clamps desktop height", () => { - expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ - width: 2, - height: 3, - eager: false, - supportsDetails: true, - }); - }); - - test("prefers explicit presentation metadata", () => { - expect( - resolveMobileItemPresentation( - { kind: "clock", height: 3 }, - { - width: 1, - height: 1, - supportsCompactSummary: true, - supportsDetailView: false, - eager: true, - }, - ), - ).toEqual({ - width: 1, - height: 1, - displayMode: "mobileSummary", - supportsDetails: false, - eager: true, - }); - }); -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts deleted file mode 100644 index 62dbd65aa..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { WidgetKind } from "@homarr/definitions"; -import type { WidgetMobilePresentation } from "@homarr/widgets"; - -export const resolveMobileItemPresentation = ( - item: { kind: WidgetKind; height: number }, - mobile: WidgetMobilePresentation | undefined, -) => ({ - width: mobile?.width ?? (item.kind === "app" ? 1 : 2), - height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), - displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), - supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app", - eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), -}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx deleted file mode 100644 index d05b1ebcf..000000000 --- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; - -import type { MutableRefObject } from "react"; -import { useMemo } from "react"; -import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; -import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; -import type { QueryKey } from "@tanstack/react-query"; -import { useIsFetching, useQueryClient } from "@tanstack/react-query"; - -import { useRequiredBoard } from "@homarr/boards/context"; -import { useEditMode } from "@homarr/boards/edit-mode"; -import { useSettings } from "@homarr/settings"; -import { translateIfNecessary } from "@homarr/translation"; -import { useI18n } from "@homarr/translation/client"; -import type { WidgetContextMenuAction } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; - -import type { SectionItem } from "~/app/[locale]/boards/_types"; -import { BoardItemContent } from "../items/item-content"; -import { useItemActions } from "../items/item-actions"; -import classes from "./mobile-board.module.css"; - -interface MobileWidgetActionsProps { - item: SectionItem; - supportsDetails: boolean; - widgetStateRef: MutableRefObject | 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 deleted file mode 100644 index 35a8a95b6..000000000 --- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { resolveMobileBoardViewport } from "./use-mobile-board"; - -describe("resolveMobileBoardViewport", () => { - test("keeps phones mobile regardless of their viewport width", () => { - expect( - resolveMobileBoardViewport({ - deviceClass: "phone", - matchesMobileWidth: false, - hasResolvedClientWidth: true, - }), - ).toEqual({ - deviceClass: "phone", - isMobile: true, - isResolved: true, - }); - }); - - test.each([ - [true, true], - [false, false], - ])("uses the 48em query for tablets (matches: %s)", (matchesMobileWidth, expectedIsMobile) => { - expect( - resolveMobileBoardViewport({ - deviceClass: "tablet", - matchesMobileWidth, - hasResolvedClientWidth: true, - }).isMobile, - ).toBe(expectedIsMobile); - }); - - test("keeps tablets unresolved until their client width is known", () => { - expect( - resolveMobileBoardViewport({ - deviceClass: "tablet", - matchesMobileWidth: false, - hasResolvedClientWidth: false, - }).isResolved, - ).toBe(false); - }); - - test.each([ - [true, true], - [false, false], - ])("keeps desktop resize behavior (matches: %s)", (matchesMobileWidth, expectedIsMobile) => { - expect( - resolveMobileBoardViewport({ - deviceClass: "desktop", - matchesMobileWidth, - hasResolvedClientWidth: true, - }), - ).toMatchObject({ - isMobile: expectedIsMobile, - isResolved: true, - }); - }); -}); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 7197aac22..84eea8508 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -1,97 +1,21 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext, useEffect, useState } from "react"; -import { Box, Group, Skeleton, Stack } from "@mantine/core"; +import { createContext, useContext } from "react"; import { useMediaQuery } from "@mantine/hooks"; export const mobileBoardMediaQuery = "(max-width: 48em)"; -export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop"; - -interface MobileBoardViewport { - deviceClass: MobileBoardDeviceClass; - isMobile: boolean; - isResolved: boolean; -} - -const MobileBoardViewportContext = createContext({ - 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 = () => ( - - - - - - - - - - - - - - - - - - - - -); +const InitialMobileBoardContext = createContext(undefined); export const MobileBoardViewportProvider = ({ - initialDeviceClass, + initialIsMobile, children, -}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { - const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); - const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { - getInitialValueInEffect: true, - }); +}: PropsWithChildren<{ initialIsMobile: boolean }>) => ( + {children} +); - useEffect(() => { - setHasResolvedClientWidth(true); - }, []); - - const viewport = resolveMobileBoardViewport({ - deviceClass: initialDeviceClass, - matchesMobileWidth, - hasResolvedClientWidth, - }); - - return ( - - {viewport.isResolved ? children : } - - ); +export const useIsMobileBoard = () => { + const initialIsMobile = useContext(InitialMobileBoardContext); + return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true }); }; - -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 7007f3ea8..e9d93f7fc 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -6,49 +6,25 @@ import { Link } from "@homarr/ui"; import { ClientBurger } from "./header/burger"; import { DesktopSearchInput, MobileSearchButton } from "./header/search"; -import { ResponsiveUserButton } from "./header/responsive-user"; import { TourTarget } from "./header/tour-target"; import { UserButton } from "./header/user"; import { HomarrLogoWithTitle } from "./logo/homarr-logo"; interface Props { logo?: ReactNode; - logoHref?: string | null; actions?: ReactNode; hasNavigation?: boolean; - withSafeArea?: boolean; - hideUserOnMobileBoard?: boolean; } -export const MainHeader = ({ - logo, - logoHref = "/", - actions, - hasNavigation = true, - withSafeArea = false, - hideUserOnMobileBoard = false, -}: Props) => { +export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { return ( - - + + {hasNavigation && } - {logoHref === null ? ( - (logo ?? ) - ) : ( - - {logo ?? } - - )} + + {logo ?? } + @@ -56,11 +32,9 @@ export const MainHeader = ({ {actions} - - - - - + + + diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index d28d46fba..e8d858b6a 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: 44, + size: "lg", }; export const HeaderButton = forwardRef((props, ref) => { @@ -29,15 +29,15 @@ export const HeaderButton = forwardRef((pr } component={Link} - {...headerButtonActionIconProps} {...props} + {...headerButtonActionIconProps} > {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 deleted file mode 100644 index b97c0383e..000000000 --- a/apps/nextjs/src/components/layout/header/responsive-user.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import type { PropsWithChildren } from "react"; - -import { useIsMobileBoard } from "../../board/use-mobile-board"; - -export const ResponsiveUserButton = ({ - hideOnMobileBoard, - children, -}: PropsWithChildren<{ hideOnMobileBoard: boolean }>) => { - const isMobileBoard = useIsMobileBoard(); - return hideOnMobileBoard && isMobileBoard ? null : children; -}; diff --git a/apps/nextjs/src/components/layout/header/search.tsx b/apps/nextjs/src/components/layout/header/search.tsx index 95ebcf9f0..8f6e4f908 100644 --- a/apps/nextjs/src/components/layout/header/search.tsx +++ b/apps/nextjs/src/components/layout/header/search.tsx @@ -28,10 +28,8 @@ 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 8ca11a043..af3d72121 100644 --- a/apps/nextjs/src/components/layout/header/user.tsx +++ b/apps/nextjs/src/components/layout/header/user.tsx @@ -4,20 +4,19 @@ import { UnstyledButton } from "@mantine/core"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { env } from "@homarr/docker/env"; -import { getI18n } from "@homarr/translation/server"; import { CurrentUserAvatar } from "~/components/user-avatar"; import { UserAvatarMenu } from "~/components/user-avatar-menu"; import { UpdateIndicator } from "./update"; export const UserButton = async () => { - const [session, t] = await Promise.all([auth(), getI18n()]); + const session = await auth(); const isAdmin = session?.user.permissions.includes("admin"); const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined; return ( - + }> diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 40e84e232..b51cf3220 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -10,13 +10,11 @@ import { navigationCollapsedAtom } from "./header/burger"; interface ClientShellProps { hasHeader?: boolean; hasNavigation?: boolean; - withSafeArea?: boolean; } export const ClientShell = ({ hasHeader = true, hasNavigation = true, - withSafeArea = false, children, }: PropsWithChildren) => { const collapsed = useAtomValue(navigationCollapsedAtom); @@ -25,7 +23,7 @@ export const ClientShell = ({ return ( ({ diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 84b8650a0..6d309ce42 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 { Center, Stack, Text, useMantineTheme } from "@mantine/core"; +import { useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; import dayjs from "dayjs"; @@ -11,7 +11,6 @@ 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"; @@ -32,7 +31,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> { setMonth: (date: Date) => void; } -const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => { +const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => { const input = { integrationIds, month: month.getMonth(), @@ -44,16 +43,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); - return ( - - ); + return ; }; interface CalendarBaseProps { @@ -62,15 +52,13 @@ interface CalendarBaseProps { month: Date; setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; - displayMode?: WidgetComponentProps<"calendar">["displayMode"]; } -const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => { +const CalendarBase = ({ isEditMode, events, month, setMonth, options }: 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(); @@ -78,38 +66,6 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod 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 5cf9d8c54..20b22d6a0 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -6,11 +6,6 @@ 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 deleted file mode 100644 index d3f6e9bf1..000000000 --- a/packages/widgets/src/common/mobile-summary.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 b5900267d..3ba53987e 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -12,16 +12,6 @@ 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; @@ -79,7 +69,6 @@ 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/index.ts b/packages/widgets/src/downloads/index.ts index 88a7e43fa..cc8f9a5b0 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -42,12 +42,6 @@ const sortColumns = [ 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 0dde96d39..061fc4b2d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -67,8 +67,6 @@ 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 d2201072e..69c1edf21 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -1,21 +1,7 @@ "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"; @@ -23,18 +9,11 @@ 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, - displayMode, -}: WidgetComponentProps<"mediaMissing">) { +export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const pageSize = Number(options.pageSize); const { data } = clientApi.widget.mediaOrganizer.getData.useQuery( @@ -53,32 +32,25 @@ export default function MediaMissingWidget({ ); - 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 ? ( @@ -144,14 +116,7 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | " } return ( - + {type === "movie" ? : } ); @@ -218,25 +183,8 @@ 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 98209d43c..eaa44be6d 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -9,12 +9,6 @@ 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 4f243c77a..05ba18fe5 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -16,21 +16,11 @@ 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, - displayMode, -}: WidgetComponentProps<"mediaReleases">) { +export default function MediaReleasesWidget({ options, integrationIds }: 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 96273e486..3e6cccbe5 100644 --- a/packages/widgets/src/media-releases/index.ts +++ b/packages/widgets/src/media-releases/index.ts @@ -5,12 +5,6 @@ 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 865a35123..c5dc5bf06 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -26,7 +26,6 @@ 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"; @@ -36,9 +35,7 @@ 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: @@ -51,10 +48,6 @@ 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 c8824bebc..1f65cd0c3 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -11,12 +11,6 @@ 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 51962c00b..fe4873e45 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -10,7 +10,6 @@ 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"; @@ -31,7 +30,6 @@ export default function MediaTranscodingWidget({ integrationIds, options, width, - displayMode, }: WidgetComponentProps<"mediaTranscoding">) { const [queuePage, setQueuePage] = useState(1); const queuePageSize = 10; @@ -47,27 +45,6 @@ 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 95f0f0388..ff4922dad 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -11,12 +11,6 @@ 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 bcdda7f93..3b04393d0 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, Box, Card, Center, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; +import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core"; import { IconClock } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -11,11 +11,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -export default function NotificationsWidget({ - options, - integrationIds, - displayMode, -}: WidgetComponentProps<"notifications">) { +export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) { const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, @@ -33,35 +29,6 @@ export default function NotificationsWidget({ [notificationIntegrations], ); - if (displayMode === "mobileSummary") { - const latestNotification = sortedNotifications[0]; - - return ( -
- {latestNotification ? ( - latestNotification.href ? ( - - - - ) : ( - - ) - ) : ( - - {t("noItems")} - - )} -
- ); - } - return ( @@ -113,23 +80,6 @@ export default function NotificationsWidget({ ); } -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 f7f34fff9..17fd13393 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -7,12 +7,6 @@ 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 edb46311c..a4dd32921 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -7,12 +7,6 @@ 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 0625129fd..ea957fab9 100644 --- a/packages/widgets/src/smart-home/execute-automation/index.ts +++ b/packages/widgets/src/smart-home/execute-automation/index.ts @@ -7,12 +7,6 @@ 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(),