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.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/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/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index c26e3e7fe..49c67d883 100644 --- a/apps/nextjs/src/components/layout/header/button.tsx +++ b/apps/nextjs/src/components/layout/header/button.tsx @@ -25,7 +25,7 @@ const headerButtonActionIconProps: ActionIconProps = { variant: "subtle", style: { border: "none" }, color: "gray", - size: 44, + size: "lg", }; export const HeaderButton = forwardRef((props, ref) => { @@ -34,15 +34,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/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/index.tsx b/packages/widgets/src/index.tsx index 445f465cf..cf131db7b 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -69,8 +69,6 @@ import * as wud from "./wud"; export type { NormalizedWidgetQuery, WidgetDefinition, - WidgetDisplayMode, - WidgetMobilePresentation, WidgetContextMenuAction, WidgetContextActionProps, WidgetOptionsSettings,