diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 4390f9acc..12b9ad2e9 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -38,6 +38,7 @@ export const createBoardContentPage = >( headerActions: , getInitialBoardAsync: getInitialBoard, withTour: true, + mobileProfileInActions: true, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx new file mode 100644 index 000000000..f28a04489 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Group, Menu, ScrollArea, Text, UnstyledButton } from "@mantine/core"; +import { IconCheck, IconChevronDown, IconLayoutBoard } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; + +import { BoardLogo } from "~/components/layout/logo/board-logo"; + +export const BoardSwitcherLogo = () => { + const board = useRequiredBoard(); + const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + const t = useI18n(); + + return ( + + + + + + + {board.name} + + + + + + + {t("board.mobile.currentBoard")} + + {boards.map((availableBoard) => { + const isCurrent = availableBoard.id === board.id; + return ( + : } + aria-current={isCurrent ? "page" : undefined} + > + {availableBoard.name} + + ); + })} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx index c68be29e9..589fdf7b7 100644 --- a/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_header-actions.tsx @@ -3,14 +3,16 @@ import { IconLayoutBoard } from "@tabler/icons-react"; import { useRequiredBoard } from "@homarr/boards/context"; +import { useI18n } from "@homarr/translation/client"; import { HeaderButton } from "~/components/layout/header/button"; export const BoardOtherHeaderActions = () => { const board = useRequiredBoard(); + const t = useI18n(); return ( - + ); diff --git a/apps/nextjs/src/app/[locale]/boards/layout.tsx b/apps/nextjs/src/app/[locale]/boards/layout.tsx new file mode 100644 index 000000000..04e3a3e4d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/layout.tsx @@ -0,0 +1,10 @@ +import type { PropsWithChildren } from "react"; +import type { Viewport } from "next"; + +export const viewport: Viewport = { + viewportFit: "cover", +}; + +export default function BoardsLayout({ children }: PropsWithChildren) { + return children; +} diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx new file mode 100644 index 000000000..ea142a886 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -0,0 +1,43 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useEffect, useRef, useState } from "react"; +import { Skeleton } from "@mantine/core"; + +import classes from "./mobile-board.module.css"; + +interface DeferredMobileItemProps extends PropsWithChildren { + eager: boolean; +} + +export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => { + const [hasMounted, setHasMounted] = useState(eager); + const wrapperRef = useRef(null); + + useEffect(() => { + if (eager || hasMounted) return; + + const element = wrapperRef.current; + if (!element || typeof IntersectionObserver === "undefined") { + setHasMounted(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + setHasMounted(true); + observer.disconnect(); + }, + { rootMargin: "200% 0px" }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [eager, hasMounted]); + + return ( +
+ {hasMounted ? children : } +
+ ); +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index 902e77372..fae316cc7 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -1,20 +1,61 @@ +.stream { + display: flex; + flex-direction: column; + gap: 10px; + padding-bottom: 10px; + overflow-x: clip; +} + .grid { display: grid; grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); grid-auto-flow: row; - grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem); + grid-auto-rows: min(calc((100vw - 42px - env(safe-area-inset-left) - env(safe-area-inset-right)) / 2), 12rem); gap: 10px; - padding-bottom: 10px; } .item { position: relative; min-width: 0; min-height: 0; + content-visibility: auto; + contain-intrinsic-size: auto 12rem; } -.item > :global(.grid-stack-item-content) { +.deferredItem { width: 100%; height: 100%; - overflow-y: auto; +} + +.deferredItem > :global(.grid-stack-item-content) { + width: 100%; + height: 100%; + overflow: hidden; +} + +.sectionHeading { + scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md)); + padding-block: var(--mantine-spacing-xs); + outline-offset: 4px; +} + +.empty { + min-height: min(60dvh, 32rem); + padding: var(--mantine-spacing-xl); +} + +.widgetAction { + position: absolute; + z-index: 11; + top: 4px; + right: 4px; + border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent); + box-shadow: var(--mantine-shadow-sm); +} + +.detailContent, +.detailContent > :global(.grid-stack-item-content) { + width: 100%; + height: 100%; + min-height: 24rem; } diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index aa06a521f..918ea6217 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -2,21 +2,98 @@ import type { CSSProperties, RefObject } from "react"; import { useMemo, useRef } from "react"; -import { Box } from "@mantine/core"; +import { Box, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { IconLayoutBoard } from "@tabler/icons-react"; import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context"; import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode"; import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack"; +import { useI18n } from "@homarr/translation/client"; +import { widgetImports } from "@homarr/widgets"; +import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; import { SectionProvider } from "../sections/section-context"; +import { DeferredMobileItem } from "./deferred-mobile-item"; import classes from "./mobile-board.module.css"; -import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout"; +import { MobileWidgetActions } from "./mobile-widget-actions"; +import { + createMobileBoardElements, + createMobileBoardItems, + getMobileRootSection, + mobileColumnCount, +} from "./mobile-layout"; +import type { MobileBoardElement, MobileBoardSectionHeading } from "./mobile-layout"; +import { resolveMobileItemPresentation } from "./mobile-presentation"; + +type MobileBoardRenderBlock = + | MobileBoardSectionHeading + | { + type: "items"; + id: string; + items: SectionItem[]; + }; + +const createRenderBlocks = (elements: MobileBoardElement[]): MobileBoardRenderBlock[] => { + const blocks: MobileBoardRenderBlock[] = []; + + for (const element of elements) { + if (element.type === "sectionHeading") { + blocks.push(element); + continue; + } + + const previousBlock = blocks.at(-1); + if (previousBlock?.type === "items") { + previousBlock.items.push(element); + } else { + blocks.push({ type: "items", id: `items-${element.id}`, items: [element] }); + } + } + + return blocks; +}; + +const getMobilePresentation = (item: SectionItem) => { + const definition = widgetImports[item.kind].definition; + return resolveMobileItemPresentation(item, "mobile" in definition ? definition.mobile : undefined); +}; + +const MobileBoardItem = ({ item }: { item: SectionItem }) => { + const widgetStateRef = useRef | null>(null); + const presentation = getMobilePresentation(item); + + return ( + + + + + + + ); +}; export const MobileBoard = () => { const board = useRequiredBoard(); + const t = useI18n(); const desktopLayout = getDesktopLayout(board); const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]); + const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]); + const renderBlocks = useMemo(() => createRenderBlocks(elements), [elements]); const rootSection = getMobileRootSection(board); const wrapperRef = useRef(null); const itemRefs = useRef>>({}); @@ -32,21 +109,47 @@ export const MobileBoard = () => { refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef }, }} > - - {items.map((item) => ( - - - - ))} + + {elements.length === 0 ? ( +
+ + + + + + {t("board.mobile.empty.title")} + + + {t("board.mobile.empty.description")} + + +
+ ) : ( + renderBlocks.map((block) => + block.type === "sectionHeading" ? ( + + {block.title} + + ) : ( + + {block.items.map((item) => ( + + ))} + + ), + ) + )}
diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index 3e06594d0..f617694d5 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -5,7 +5,12 @@ import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock"; import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock"; import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock"; import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock"; -import { createMobileBoardItems, getMobileRootSection } from "./mobile-layout"; +import { + createMobileBoardElements, + createMobileBoardItems, + getMobileRootSection, + getMobileSectionAnchorId, +} from "./mobile-layout"; describe("createMobileBoardItems", () => { test("selects the first root section in visual order", () => { @@ -93,4 +98,86 @@ describe("createMobileBoardItems", () => { expect(result.map((item) => item.id)).toEqual(["second", "first"]); expect(result[1]).toMatchObject({ width: 1, height: 1 }); }); + + test("emits named category and dynamic section headings in visual order", () => { + const board = new BoardMockBuilder().build(); + const desktopLayoutId = board.layouts.at(0)?.id; + if (!desktopLayoutId) throw new Error("Expected a desktop layout"); + + const category = new CategorySectionMockBuilder({ id: "category", name: " Services ", yOffset: 0 }).build(); + const dynamicSection = new DynamicSectionMockBuilder({ + id: "dynamic", + options: { title: "Monitoring", borderColor: "", customCssClasses: [] }, + }) + .addLayout({ layoutId: desktopLayoutId, parentSectionId: category.id, xOffset: 1, yOffset: 0 }) + .build(); + const nestedDynamicSection = new DynamicSectionMockBuilder({ + id: "nested-dynamic", + options: { title: "Details", borderColor: "", customCssClasses: [] }, + }) + .addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 }) + .build(); + + board.sections.push(category, dynamicSection, nestedDynamicSection); + board.items.push( + new ItemMockBuilder({ id: "before-dynamic" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: category.id, xOffset: 0, yOffset: 0 }) + .build(), + new ItemMockBuilder({ id: "inside-dynamic" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id, xOffset: 0, yOffset: 0 }) + .build(), + new ItemMockBuilder({ id: "inside-nested" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id }) + .build(), + ); + + const result = createMobileBoardElements(board, desktopLayoutId); + + expect( + result.map((element) => (element.type === "sectionHeading" ? `heading:${element.title}` : `item:${element.id}`)), + ).toEqual([ + "heading:Services", + "item:before-dynamic", + "heading:Monitoring", + "item:inside-dynamic", + "heading:Details", + "item:inside-nested", + ]); + expect(result[0]).toMatchObject({ + type: "sectionHeading", + sectionId: category.id, + anchorId: getMobileSectionAnchorId(category.id), + }); + }); + + test("omits headings for empty and unnamed sections while preserving their items", () => { + const board = new BoardMockBuilder().build(); + const desktopLayoutId = board.layouts.at(0)?.id; + if (!desktopLayoutId) throw new Error("Expected a desktop layout"); + + const emptyNamedCategory = new CategorySectionMockBuilder({ + id: "empty-category", + name: "Empty", + yOffset: 0, + }).build(); + const unnamedCategory = new CategorySectionMockBuilder({ id: "unnamed-category", name: " ", yOffset: 1 }).build(); + const unnamedDynamicSection = new DynamicSectionMockBuilder({ id: "unnamed-dynamic" }) + .addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id }) + .build(); + + board.sections.push(emptyNamedCategory, unnamedCategory, unnamedDynamicSection); + board.items.push( + new ItemMockBuilder({ id: "inside-unnamed" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: unnamedDynamicSection.id }) + .build(), + new ItemMockBuilder({ id: "orphan" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section" }) + .build(), + ); + + const result = createMobileBoardElements(board, desktopLayoutId); + + expect(result.map((element) => element.type)).toEqual(["item", "item"]); + expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]); + }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index ab8bd4d65..1017856a4 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -1,7 +1,8 @@ -import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types"; +import type { Board, CategorySection, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types"; export const mobileColumnCount = 2; const mobileMaxHeight = 3; +const mobileSectionAnchorPrefix = "mobile-board-section-"; type PositionedElement = DynamicSectionItem | SectionItem; type PositionedLayout = { @@ -12,6 +13,16 @@ type PositionedLayout = { height: number; }; +export type MobileBoardSectionHeading = { + type: "sectionHeading"; + id: string; + sectionId: string; + title: string; + anchorId: string; +}; + +export type MobileBoardElement = SectionItem | MobileBoardSectionHeading; + const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) => elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset; @@ -28,6 +39,8 @@ export const getMobileRootSection = (board: Board) => yOffset: 0, }; +export const getMobileSectionAnchorId = (sectionId: string) => `${mobileSectionAnchorPrefix}${sectionId}`; + const getLayout = ( layouts: TLayout[], desktopLayoutId: string, @@ -38,7 +51,20 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => { +const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => { + const title = (section.kind === "category" ? section.name : section.options.title).trim(); + if (title.length === 0) return null; + + return { + type: "sectionHeading", + id: `section-heading-${section.id}`, + sectionId: section.id, + title, + anchorId: getMobileSectionAnchorId(section.id), + }; +}; + +export const createMobileBoardElements = (board: Board, desktopLayoutId: string): MobileBoardElement[] => { const rootSections = getMobileRootSections(board); const defaultSectionId = rootSections[0]?.id ?? "mobile"; const layoutPriority = new Map( @@ -80,38 +106,66 @@ export const createMobileBoardItems = (board: Board, desktopLayoutId: string): S elementsBySection.set(parentSectionId, elements); } - const result: SectionItem[] = []; + const result: MobileBoardElement[] = []; const visitedItems = new Set(); const visitedSections = new Set(); - const appendSectionItems = (sectionId: string) => { - if (visitedSections.has(sectionId)) return; + const createSectionElements = ( + section: CategorySection | DynamicSectionItem | (typeof rootSections)[number], + ): { elements: MobileBoardElement[]; items: SectionItem[] } => { + if (visitedSections.has(section.id)) return { elements: [], items: [] }; + const sectionId = section.id; visitedSections.add(sectionId); + const sectionElements: MobileBoardElement[] = []; + const sectionItems: SectionItem[] = []; for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) { if (element.type === "section") { - appendSectionItems(element.id); + const nestedResult = createSectionElements(element); + sectionElements.push(...nestedResult.elements); + sectionItems.push(...nestedResult.items); continue; } if (!visitedItems.has(element.id)) { visitedItems.add(element.id); - result.push(element); + sectionElements.push(element); + sectionItems.push(element); } } + + if (sectionItems.length === 0) return { elements: [], items: [] }; + + const heading = section.kind === "empty" ? null : createSectionHeading(section); + return { + elements: heading ? [heading, ...sectionElements] : sectionElements, + items: sectionItems, + }; }; - rootSections.forEach((section) => appendSectionItems(section.id)); - dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id)); + rootSections.forEach((section) => result.push(...createSectionElements(section).elements)); + dynamicSections + .toSorted(comparePosition) + .forEach((section) => result.push(...createSectionElements(section).elements)); items.toSorted(comparePosition).forEach((item) => { if (!visitedItems.has(item.id)) result.push(item); }); - return result.map((item, index) => ({ - ...item, - xOffset: 0, - yOffset: index, - width: Math.max(1, Math.min(item.width, mobileColumnCount)), - height: Math.max(1, Math.min(item.height, mobileMaxHeight)), - })); + let itemIndex = 0; + return result.map((element) => { + if (element.type === "sectionHeading") return element; + + return { + ...element, + xOffset: 0, + yOffset: itemIndex++, + width: Math.max(1, Math.min(element.width, mobileColumnCount)), + height: Math.max(1, Math.min(element.height, mobileMaxHeight)), + }; + }); }; + +export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => + createMobileBoardElements(board, desktopLayoutId).filter( + (element): element is SectionItem => element.type === "item", + ); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts new file mode 100644 index 000000000..c2e6c1197 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { resolveMobileItemPresentation } from "./mobile-presentation"; + +describe("resolveMobileItemPresentation", () => { + test("renders apps as eager compact tiles", () => { + expect(resolveMobileItemPresentation({ kind: "app", height: 8 }, undefined)).toMatchObject({ + width: 1, + height: 1, + eager: true, + supportsDetails: false, + }); + }); + + test("defaults widgets to full width and clamps desktop height", () => { + expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ + width: 2, + height: 3, + eager: false, + supportsDetails: true, + }); + }); + + test("prefers explicit presentation metadata", () => { + expect( + resolveMobileItemPresentation( + { kind: "clock", height: 3 }, + { + width: 1, + height: 1, + supportsCompactSummary: true, + supportsDetailView: false, + eager: true, + }, + ), + ).toEqual({ + width: 1, + height: 1, + displayMode: "mobileSummary", + supportsDetails: false, + eager: true, + }); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts new file mode 100644 index 000000000..62dbd65aa --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -0,0 +1,13 @@ +import type { WidgetKind } from "@homarr/definitions"; +import type { WidgetMobilePresentation } from "@homarr/widgets"; + +export const resolveMobileItemPresentation = ( + item: { kind: WidgetKind; height: number }, + mobile: WidgetMobilePresentation | undefined, +) => ({ + width: mobile?.width ?? (item.kind === "app" ? 1 : 2), + height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), + displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), + supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app", + eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx new file mode 100644 index 000000000..d05b1ebcf --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -0,0 +1,166 @@ +"use client"; + +import type { MutableRefObject } from "react"; +import { useMemo } from "react"; +import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; +import type { QueryKey } from "@tanstack/react-query"; +import { useIsFetching, useQueryClient } from "@tanstack/react-query"; + +import { useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { useSettings } from "@homarr/settings"; +import { translateIfNecessary } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; +import type { WidgetContextMenuAction } from "@homarr/widgets"; +import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; + +import type { SectionItem } from "~/app/[locale]/boards/_types"; +import { BoardItemContent } from "../items/item-content"; +import { useItemActions } from "../items/item-actions"; +import classes from "./mobile-board.module.css"; + +interface MobileWidgetActionsProps { + item: SectionItem; + supportsDetails: boolean; + widgetStateRef: MutableRefObject | null>; +} + +export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { + const [actionsOpened, actionsDisclosure] = useDisclosure(false); + const [detailsOpened, detailsDisclosure] = useDisclosure(false); + const t = useI18n(); + const settings = useSettings(); + const board = useRequiredBoard(); + const [isEditMode] = useEditMode(); + const { updateItemOptions } = useItemActions(); + const definition = widgetImports[item.kind].definition; + const queryClient = useQueryClient(); + const queryKey = useMemo( + () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]), + [definition, item.kind], + ); + const isFetching = useIsFetching({ queryKey }) > 0; + const options = useMemo( + () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, + [item.kind, item.options, settings], + ); + const contextActions = useMemo(() => { + if (!("contextActions" in definition) || typeof definition.contextActions !== "function") return []; + + return definition.contextActions({ + options, + setOptions: (partial: Record) => + updateItemOptions({ + itemId: item.id, + newOptions: { ...options, ...partial }, + }), + integrationIds: item.integrationIds, + context: { isEditMode, boardId: board.id, itemId: item.id }, + widgetStateRef, + } as never) as WidgetContextMenuAction[]; + }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]); + const visibleContextActions = contextActions.filter((action) => !action.hidden); + const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); + const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0; + + if (!hasActions) return null; + + const refresh = () => { + void queryClient.refetchQueries({ queryKey, type: "all" }); + actionsDisclosure.close(); + }; + + return ( + <> + + + + + + + {supportsDetails && ( + + )} + {visibleContextActions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} + + + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/board/use-mobile-board.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts new file mode 100644 index 000000000..35a8a95b6 --- /dev/null +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; + +import { resolveMobileBoardViewport } from "./use-mobile-board"; + +describe("resolveMobileBoardViewport", () => { + test("keeps phones mobile regardless of their viewport width", () => { + expect( + resolveMobileBoardViewport({ + deviceClass: "phone", + matchesMobileWidth: false, + hasResolvedClientWidth: true, + }), + ).toEqual({ + deviceClass: "phone", + isMobile: true, + isResolved: true, + }); + }); + + test.each([ + [true, true], + [false, false], + ])("uses the 48em query for tablets (matches: %s)", (matchesMobileWidth, expectedIsMobile) => { + expect( + resolveMobileBoardViewport({ + deviceClass: "tablet", + matchesMobileWidth, + hasResolvedClientWidth: true, + }).isMobile, + ).toBe(expectedIsMobile); + }); + + test("keeps tablets unresolved until their client width is known", () => { + expect( + resolveMobileBoardViewport({ + deviceClass: "tablet", + matchesMobileWidth: false, + hasResolvedClientWidth: false, + }).isResolved, + ).toBe(false); + }); + + test.each([ + [true, true], + [false, false], + ])("keeps desktop resize behavior (matches: %s)", (matchesMobileWidth, expectedIsMobile) => { + expect( + resolveMobileBoardViewport({ + deviceClass: "desktop", + matchesMobileWidth, + hasResolvedClientWidth: true, + }), + ).toMatchObject({ + isMobile: expectedIsMobile, + isResolved: true, + }); + }); +}); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 84eea8508..7197aac22 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -1,21 +1,97 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; +import { Box, Group, Skeleton, Stack } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; export const mobileBoardMediaQuery = "(max-width: 48em)"; -const InitialMobileBoardContext = createContext(undefined); +export type MobileBoardDeviceClass = "phone" | "tablet" | "desktop"; -export const MobileBoardViewportProvider = ({ - initialIsMobile, - children, -}: PropsWithChildren<{ initialIsMobile: boolean }>) => ( - {children} +interface MobileBoardViewport { + deviceClass: MobileBoardDeviceClass; + isMobile: boolean; + isResolved: boolean; +} + +const MobileBoardViewportContext = createContext({ + deviceClass: "desktop", + isMobile: false, + isResolved: true, +}); + +export const resolveMobileBoardViewport = ({ + deviceClass, + matchesMobileWidth, + hasResolvedClientWidth, +}: { + deviceClass: MobileBoardDeviceClass; + matchesMobileWidth: boolean; + hasResolvedClientWidth: boolean; +}): MobileBoardViewport => ({ + deviceClass, + isMobile: deviceClass === "phone" || matchesMobileWidth, + isResolved: deviceClass !== "tablet" || hasResolvedClientWidth, +}); + +const MobileBoardViewportSkeleton = () => ( + + + + + + + + + + + + + + + + + + + + ); -export const useIsMobileBoard = () => { - const initialIsMobile = useContext(InitialMobileBoardContext); - return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true }); +export const MobileBoardViewportProvider = ({ + initialDeviceClass, + children, +}: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { + const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); + const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { + getInitialValueInEffect: true, + }); + + useEffect(() => { + setHasResolvedClientWidth(true); + }, []); + + const viewport = resolveMobileBoardViewport({ + deviceClass: initialDeviceClass, + matchesMobileWidth, + hasResolvedClientWidth, + }); + + return ( + + {viewport.isResolved ? children : } + + ); }; + +export const useMobileBoardViewport = () => useContext(MobileBoardViewportContext); + +export const useIsMobileBoard = () => useMobileBoardViewport().isMobile; diff --git a/apps/nextjs/src/components/layout/header/button.tsx b/apps/nextjs/src/components/layout/header/button.tsx index 49c67d883..c26e3e7fe 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: "lg", + size: 44, }; export const HeaderButton = forwardRef((props, ref) => { @@ -34,15 +34,15 @@ export const HeaderButton = forwardRef((pr } component={Link} - {...props} {...headerButtonActionIconProps} + {...props} > {props.children} ); } return ( - + {props.children} ); diff --git a/apps/nextjs/src/components/layout/header/responsive-user.tsx b/apps/nextjs/src/components/layout/header/responsive-user.tsx new file mode 100644 index 000000000..b97c0383e --- /dev/null +++ b/apps/nextjs/src/components/layout/header/responsive-user.tsx @@ -0,0 +1,13 @@ +"use client"; + +import type { PropsWithChildren } from "react"; + +import { useIsMobileBoard } from "../../board/use-mobile-board"; + +export const ResponsiveUserButton = ({ + hideOnMobileBoard, + children, +}: PropsWithChildren<{ hideOnMobileBoard: boolean }>) => { + const isMobileBoard = useIsMobileBoard(); + return hideOnMobileBoard && isMobileBoard ? null : children; +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1a0551c29..27abdf835 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5061,6 +5061,18 @@ } }, "board": { + "mobile": { + "actions": "Widget actions", + "details": "Open widget details", + "empty": { + "title": "This board is empty", + "description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically." + }, + "more": "More", + "profile": "Profile and account", + "sections": "Jump to section", + "currentBoard": "Current board" + }, "action": { "addContent": "Add board content", "settings": "Board settings", @@ -5305,7 +5317,7 @@ "description": "Configure the layout used on desktop and larger screens." }, "mobile": { - "description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Categories and dynamic sections are flattened into the item list." + "description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers." } }, "background": { diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 27190ba6d..5cc0cfd58 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -10,13 +10,13 @@ import dayjs from "dayjs"; 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 { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import actionTargetClasses from "../common/action-target.module.css"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { CalendarDay } from "./calender-day"; @@ -27,7 +27,6 @@ import { splitEvents, toCalendarDateKey, } from "./calendar-events"; -import type { CalendarEventWithSource } from "./calendar-event-list"; import { CalendarEventList } from "./calendar-event-list"; import classes from "./component.module.css"; @@ -54,7 +53,6 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">) events={[]} failedIntegrations={[]} isPending={false} - queryError={undefined} month={month} setMonth={setMonth} /> @@ -81,20 +79,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d const data = getUsableWidgetQueryData(calendarQuery); const { isPending } = calendarQuery; - const events = useMemo( - () => - data?.flatMap(({ events: integrationEvents, integration, error }) => - error - ? [] - : integrationEvents.map( - (event): CalendarEventWithSource => ({ - ...event, - source: { integrationId: integration.id, integrationName: integration.name }, - }), - ), - ) ?? [], - [data], - ); + const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); const failedIntegrations = data?.flatMap(({ integration, error }) => error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], @@ -106,7 +91,6 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d events={events} failedIntegrations={failedIntegrations} isPending={isPending} - queryError={calendarQuery.error} month={month} setMonth={setMonth} options={options} @@ -117,10 +101,9 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d interface CalendarBaseProps { isEditMode: boolean; - events: CalendarEventWithSource[]; + events: CalendarEvent[]; failedIntegrations: { integrationId: string; integrationName: string; error: string }[]; isPending: boolean; - queryError: unknown; month: Date; setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; @@ -132,13 +115,11 @@ const CalendarBase = ({ events, failedIntegrations, isPending, - queryError, month, setMonth, options, displayMode, }: CalendarBaseProps) => { - const t = useI18n(); const locale = useCurrentIntlLocale(); const { firstDayOfWeek } = useSettings(); const board = useRequiredBoard(); @@ -170,8 +151,6 @@ const CalendarBase = ({ events={agendaEvents} failedIntegrations={failedIntegrations} isPending={isPending} - queryError={queryError} - queryErrorLabel={t("widget.calendar.name")} isEditMode={isEditMode} locale={locale} month={month} @@ -182,10 +161,9 @@ const CalendarBase = ({ return ( - {(failedIntegrations.length > 0 || Boolean(queryError)) && ( + {failedIntegrations.length > 0 && ( - )} - - - - + {failedIntegrations.length > 0 && } @@ -339,7 +310,7 @@ const CalendarAgenda = ({ ) : events.length > 0 ? ( - + ) : ( diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index e7657b918..45b040009 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -6,6 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, + supportsAdvancedFocus: true, queryKey: [["widget", "weather", "atLocation"]], queryMatcher: ({ input }, scope) => { const location = scope.options.weatherLocation; diff --git a/packages/widgets/src/common/mobile-summary.tsx b/packages/widgets/src/common/mobile-summary.tsx new file mode 100644 index 000000000..d3f6e9bf1 --- /dev/null +++ b/packages/widgets/src/common/mobile-summary.tsx @@ -0,0 +1,29 @@ +import { Center, Group, Stack, Text } from "@mantine/core"; + +interface WidgetMobileSummaryProps { + value: number | string; + label: string; + description?: string; +} + +export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) { + return ( +
+ + + + {value} + + + {label} + + + {description !== undefined && ( + + {description} + + )} + +
+ ); +} diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index dcfe40d29..32ab31afa 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -53,18 +53,11 @@ import { showErrorNotification } from "@homarr/notifications"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { HomarrDataTable } from "../common/homarr-data-table"; import { formatLocalizedDateTime } from "../common/locale"; import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout"; -import { - DOWNLOAD_COLUMN_ACCESSORS, - filterDownloadItemsByStatus, - getAvailableDownloadStates, - getDownloadColumnAccessors, -} from "./helpers"; +import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers"; dayjs.extend(relativeTime); @@ -91,7 +84,6 @@ interface ColumnContext { hasTorrents: boolean; hasMultipleClients: boolean; hasMultipleTypes: boolean; - isAdvanced: boolean; size: SizeConfig; } @@ -151,8 +143,8 @@ const columnVisibilityChecks: Partial boo upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, ratio: (ctx) => ctx.hasTorrents, sent: (ctx) => ctx.hasTorrents, - integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients, - type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes, + integration: (ctx) => ctx.hasMultipleClients, + type: (ctx) => ctx.hasMultipleTypes, downSpeed: (ctx) => ctx.size.showSpeedColumns, time: (ctx) => ctx.size.showTimeColumn, state: (ctx) => ctx.size.showStateColumn, @@ -179,6 +171,24 @@ function progressColor(state: DownloadState, progress: number): string { return "blue"; } +const columnAccessors = [ + "name", + "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", + "ratio", + "received", + "sent", + "category", + "integration", + "index", + "type", +] as const; + function toCategoryArray(category: string | string[]): string[] { if (Array.isArray(category)) return category; return [category]; @@ -310,17 +320,10 @@ export default function DownloadClientsWidget({ integrationIds, limitPerIntegration: options.limitPerIntegration, }); - const currentItems = getUsableWidgetQueryData(downloadsQuery); - const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]); + const currentItems = getUsableWidgetQueryData(downloadsQuery) ?? []; const { isFetching } = downloadsQuery; const t = useScopedI18n("widget.downloads"); - const queryIndicators = ( - - - - - ); const [clientFilter, setClientFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); @@ -367,7 +370,7 @@ export default function DownloadClientsWidget({ }, [options.defaultSort, defaultSortDirection]); const eligibleData = useMemo(() => { - return availableItems + return currentItems .filter(({ integration }) => integrationIds.includes(integration.id)) .flatMap((pair) => pair.data.items @@ -396,7 +399,7 @@ export default function DownloadClientsWidget({ }), ); }, [ - availableItems, + currentItems, integrationIds, integrationInteractSet, mutateDeleteItem, @@ -419,7 +422,7 @@ export default function DownloadClientsWidget({ }, [data, sortStatus]); const clients = useMemo(() => { - return availableItems + return currentItems .filter(({ integration }) => integrationIds.includes(integration.id)) .map(({ integration, data: clientData }): ExtendedClientStatus => { const interact = integrationInteractSet.has(integration.id); @@ -456,7 +459,7 @@ export default function DownloadClientsWidget({ }; }); }, [ - availableItems, + currentItems, integrationIds, integrationInteractSet, options.applyFilterToRatio, @@ -469,13 +472,10 @@ export default function DownloadClientsWidget({ const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1; const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]); - const optionsColumnSet = useMemo( - () => new Set(getDownloadColumnAccessors(options.columns, isAdvanced)), - [isAdvanced, options.columns], - ); + const optionsColumnSet = useMemo(() => new Set(options.columns), [options.columns]); const columnContext = useMemo( - () => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }), - [optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size], + () => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }), + [optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size], ); let progressColumnWidth = 120; @@ -688,7 +688,7 @@ export default function DownloadClientsWidget({ const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, - columnAccessors: DOWNLOAD_COLUMN_ACCESSORS, + columnAccessors, columnOrder: options.columnOrder, columnWidths: options.columnWidths, itemId, @@ -733,16 +733,11 @@ export default function DownloadClientsWidget({ return { stateCounts, totalSize, completedSize, totalItems: data.length }; }, [data]); - if (!isAdvanced && options.columns.length === 0) { + if (options.columns.length === 0) { return ( - - - {queryIndicators} - -
- {t("errors.noColumns")} -
-
+
+ {t("errors.noColumns")} +
); } @@ -764,13 +759,10 @@ export default function DownloadClientsWidget({ )} - - {queryIndicators} - ({ columns: factory.multiSelect({ defaultValue: ["name", "progress", "downSpeed", "time", "state"], - options: DOWNLOAD_COLUMN_ACCESSORS.map((value) => ({ + options: columnsList.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index cf131db7b..445f465cf 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -69,6 +69,8 @@ import * as wud from "./wud"; export type { NormalizedWidgetQuery, 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 15ad851f2..be7366f3a 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -28,7 +28,6 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { NoIntegrationDataError } from "../errors/no-data-integration"; @@ -46,19 +45,18 @@ export default function MediaMissingWidget({ }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const isAdvanced = displayMode === "advanced"; - const showMissing = isAdvanced || options.showMissing; - const showQueued = isAdvanced || options.showQueued; const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize); const input = { integrationIds, pageSize }; useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]); - const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); - const data = getUsableWidgetQueryData(mediaQuery); - const [selectedTab, setSelectedTab] = useState(showMissing ? "missing" : "queued"); - const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued); + const data = getUsableWidgetQueryData( + clientApi.widget.mediaOrganizer.getData.useQuery(input, { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }), + ); + const [selectedTab, setSelectedTab] = useState(options.showMissing ? "missing" : "queued"); + const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued); useEffect(() => { if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab); @@ -66,7 +64,7 @@ export default function MediaMissingWidget({ if (!data) return ; if (data.length === 0) throw new NoIntegrationDataError(); - if (!showMissing && !showQueued) + if (!options.showMissing && !options.showQueued) return (
@@ -81,7 +79,7 @@ export default function MediaMissingWidget({ const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0); const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error)); - const enabledPanelCount = Number(showMissing) + Number(showQueued); + const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued); const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width; const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160; const isShort = !isAdvanced && height > 0 && height < 180; @@ -104,12 +102,7 @@ export default function MediaMissingWidget({ ) : ( {entries.map(({ item, integrationId }) => ( - + ))} )} @@ -128,17 +121,13 @@ export default function MediaMissingWidget({ ))} ); - const queryFailure = ; if (isAdvanced) { return ( - - {queryFailure} - {partialFailures} - + {partialFailures} - {showMissing && ( + {options.showMissing && ( @@ -149,7 +138,7 @@ export default function MediaMissingWidget({ {renderPanel(missing, t("empty.missing"))} )} - {showQueued && ( + {options.showQueued && ( @@ -174,29 +163,26 @@ export default function MediaMissingWidget({ h="100%" style={{ display: "flex", flexDirection: "column" }} > - - {queryFailure} - {partialFailures} - + {partialFailures} - {showMissing && ( + {options.showMissing && ( }> {tabLabel(t("tab.missing"), missing.length, missingCount)} )} - {showQueued && ( + {options.showQueued && ( }> {tabLabel(t("tab.queued"), queued.length, queuedCount)} )} - {showMissing && ( + {options.showMissing && ( {renderPanel(missing, t("empty.missing"))} )} - {showQueued && ( + {options.showQueued && ( {renderPanel(queued, t("empty.queued"))} @@ -320,15 +306,7 @@ const CardShell = ({ ); }; -const MediaCard = ({ - item, - density, - showQueueDetails, -}: { - item: MissingMediaItem | QueuedMediaItem; - density: Density; - showQueueDetails: boolean; -}) => { +const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => { const isQueued = "percentComplete" in item; return ( @@ -345,11 +323,6 @@ const MediaCard = ({ {item.type === "episode" ? item.title : item.year} )} - {isQueued && showQueueDetails && ( - - {[item.status, item.timeLeft].filter(Boolean).join(" ยท ")} - - )} {isQueued && } diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 1bb724044..9893a91fb 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -31,7 +31,6 @@ import { OverflowBadge } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import classes from "./component.module.css"; @@ -45,43 +44,36 @@ export default function MediaReleasesWidget({ }: WidgetComponentProps<"mediaReleases">) { const t = useI18n(); const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds }); - const response = getUsableWidgetQueryData(releasesQuery); + const releases = getUsableWidgetQueryData(releasesQuery); if (isInitialWidgetQueryPending(releasesQuery)) return ; - if (!response) return ; - - const { releases, failedIntegrations } = response; + if (!releases || releases.length === 0) return ; const isAdvanced = displayMode === "advanced"; return ( - {releases.length === 0 ? ( - - ) : ( - - - {releases.map((item, index) => ( - - {!isAdvanced && index !== 0 && options.layout === "poster" && } - - - ))} - - - )} - {(failedIntegrations.length > 0 || releasesQuery.error) && ( - - + + + {releases.map((item, index) => ( + + {!isAdvanced && index !== 0 && options.layout === "poster" && } + + + ))} + + + {releasesQuery.error && ( + - + )} ); } interface ItemProps { - item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number]; + item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; isAdvanced: boolean; width: number; @@ -219,7 +211,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { /> )} {isAdvanced && item.description && ( - + {item.description} )} @@ -238,17 +230,9 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { )} - {(isAdvanced || options.showSource) && - (isAdvanced ? ( - - - - {item.integration.name} - - - ) : ( - - ))} + {(isAdvanced || options.showSource) && ( + + )} )} diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index a651bbf73..eddf8adea 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { getQueryKey } from "@trpc/react-query"; import { - Box, Center, Divider, Group, @@ -27,7 +26,6 @@ import { views } from "."; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; @@ -68,8 +66,7 @@ export default function MediaTranscodingWidget({ useWidgetRuntimeQueries(widgetRuntimeRef, [ getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"), ]); - const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input); - const transcodingData = getUsableWidgetQueryData(transcodingQuery); + const transcodingData = getUsableWidgetQueryData(clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input)); const [view, setView] = useState(options.defaultView); const t = useI18n("widget.mediaTranscoding"); @@ -97,16 +94,10 @@ export default function MediaTranscodingWidget({ const queuePageCount = totalQueuePages ?? 1; const isTiny = !isAdvanced && (width < 280 || height < 140); const footerLayout = getTranscodingFooterLayout(width, height); - const queryIndicator = ( - - - - ); if (isAdvanced) { return ( - - {queryIndicator} + = 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm"> @@ -136,8 +127,7 @@ export default function MediaTranscodingWidget({ } return ( - - {queryIndicator} + {view === "workers" ? ( ) : view === "queue" ? ( diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index a003ae6e8..59f42bd96 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -9,25 +9,21 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useTimeAgo } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; +import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; -import type { WidgetComponentProps } from "../definition"; -import { getNotificationDisplay } from "./display"; export default function NotificationsWidget({ options, integrationIds, width, height, - displayMode, }: WidgetComponentProps<"notifications">) { const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, }); - const notificationData = getUsableWidgetQueryData(notificationsQuery); - const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]); + const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? []; const { isPending } = notificationsQuery; const t = useI18n(); @@ -53,23 +49,12 @@ export default function NotificationsWidget({ const isDense = width < 280 || height < 180; const isRoomy = width >= 360 && height >= 220; const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4; - const notificationDisplay = getNotificationDisplay({ - displayMode, - hideLogos: options.hideLogos, - isRoomy, - bodyLineClamp, - }); const columns = width >= 720 ? 2 : 1; const spacing = isRoomy ? "sm" : "xs"; return ( - {notificationsQuery.error && ( - - - - )} {failedIntegrations.length > 0 && ( {failedIntegrations.map((integration) => ( @@ -110,7 +95,7 @@ export default function NotificationsWidget({ }} > - {notificationDisplay.showLogos && notification.source?.iconUrl && ( + {!options.hideLogos && notification.source?.iconUrl && ( {notification.body} @@ -136,11 +121,7 @@ export default function NotificationsWidget({ diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 60bdc8758..6358d8303 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -6,7 +6,7 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("notifications", { - supportsAdvancedFocus: true, + supportsAdvancedFocus: false, icon: IconMessage, queryKey: [["widget", "notifications", "getNotifications"]], queryMatcher: ({ input }, scope) =>