diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts index 93b387de7..461e1b280 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.spec.ts @@ -23,7 +23,12 @@ describe("dnd grid coordinates", () => { test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => { const scale = 0.75; const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale }; - const initialShape = { left: target.left, top: target.top, width: 412 * scale, height: 200 * scale }; + const initialShape = { + left: target.left, + top: target.top, + width: getLogicalTrackSize(2) * scale, + height: 200 * scale, + }; const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 }; expect( diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 97085a624..9a9317657 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -3,6 +3,7 @@ import type { CSSProperties } from "react"; import { useMemo } from "react"; import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core"; +import { useElementSize } from "@mantine/hooks"; import { IconLink } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -17,6 +18,8 @@ import classes from "./bookmark.module.css"; type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; +const COMPACT_GRID_GAP = 4; + export interface BookmarkDisplayItem { id: string; name: string; @@ -29,7 +32,6 @@ export interface CompactBookmarkLayout { columns: number; hideHostname: boolean; hideTitle: boolean; - minimumItemWidth: number; minimumItemSize: number; } @@ -42,9 +44,14 @@ export function getCompactBookmarkLayout( const count = Math.max(1, itemCount); const isGrid = layout === "grid" || layout === "gridHorizontal"; const targetWidth = layout === "gridHorizontal" ? 180 : 104; - const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1; + let columns = 1; + if (isGrid) { + columns = Math.max(1, Math.min(count, Math.floor((width + COMPACT_GRID_GAP) / (targetWidth + COMPACT_GRID_GAP)))); + } else if (layout === "row") { + columns = count; + } const rows = Math.max(1, Math.ceil(count / columns)); - const cellWidth = width / columns; + const cellWidth = (width - (columns - 1) * COMPACT_GRID_GAP) / columns; const cellHeight = height / rows; const usesHorizontalItems = layout === "column" || layout === "gridHorizontal"; @@ -52,7 +59,6 @@ export function getCompactBookmarkLayout( columns, hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96), hideTitle: cellWidth < 64 || cellHeight < 40, - minimumItemWidth: targetWidth, minimumItemSize: usesHorizontalItems ? 48 : 96, }; } @@ -65,6 +71,7 @@ export default function BookmarksWidget({ displayMode, }: WidgetComponentProps<"bookmarks">) { const board = useRequiredBoard(); + const { ref: compactContentRef, width: compactContentWidth } = useElementSize(); const apps = getUsableWidgetQueryData( clientApi.app.byIds.useQuery(options.items, { @@ -104,8 +111,8 @@ export default function BookmarksWidget({ ); const compactLayout = useMemo( - () => getCompactBookmarkLayout(width, height, data.length, options.layout), - [width, height, data.length, options.layout], + () => getCompactBookmarkLayout(compactContentWidth || width, height, data.length, options.layout), + [compactContentWidth, data.length, height, options.layout, width], ); const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon); const compactHideHostname = @@ -134,11 +141,11 @@ export default function BookmarksWidget({ scrollbarSize={6} style={{ flex: 1, minHeight: 0 }} > -
+
{options.layout === "grid" || options.layout === "gridHorizontal" ? ( { const board = useRequiredBoard(); return ( - + {data.map((app) => { const href = getSafeAppHref(app.href); return ( @@ -373,7 +380,7 @@ const GridLayout = ({ rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined} key={app.id} style={{ - flex: `1 0 calc(${minimumItemWidth}px * var(--mantine-scale, 1))`, + flex: `1 0 calc((100% - calc(${COMPACT_GRID_GAP * (columns - 1)}px * var(--mantine-scale, 1))) / ${columns})`, minWidth: 0, minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`, }} diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index 854c9c303..bf3e14653 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Badge, Box, @@ -36,8 +36,32 @@ import classes from "./component.module.css"; import type { MediaMissingTab } from "./tabs"; import { resolveMediaMissingTab } from "./tabs"; +type Density = "thin" | "compact" | "comfortable"; + const MIN_CARD_WIDTH = 280; const MAX_COLUMNS = 4; +const GRID_HORIZONTAL_PADDING = 20; +const GRID_COLUMN_GAP = 10; + +const columnBreakpoints = Array.from({ length: MAX_COLUMNS }, (_, index) => { + const columns = MAX_COLUMNS - index; + return { + columns, + minimumWidth: columns * MIN_CARD_WIDTH + (columns - 1) * GRID_COLUMN_GAP, + }; +}); + +const getMediaMissingGridLayout = (panelWidth: number) => { + const contentWidth = Math.max(0, panelWidth - GRID_HORIZONTAL_PADDING); + const breakpoint = columnBreakpoints.find((candidate) => contentWidth >= candidate.minimumWidth); + return { columns: breakpoint?.columns ?? 1, contentWidth }; +}; + +const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: boolean): Density => { + if (isThin) return "thin"; + if (contentWidth > 0 && contentWidth / columns < 180) return "compact"; + return "comfortable"; +}; export default function MediaMissingWidget({ integrationIds, @@ -70,6 +94,12 @@ export default function MediaMissingWidget({ if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab); }, [activeTab, selectedTab]); + const enabledPanelCount = Number(showMissing) + Number(showQueued); + const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width; + const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160; + const gridLayout = useMemo(() => getMediaMissingGridLayout(panelWidth), [panelWidth]); + const density = getMediaMissingDensity(gridLayout.contentWidth, gridLayout.columns, isThin); + if (!data) return ; if (data.length === 0) throw new NoIntegrationDataError(); if (!showMissing && !showQueued) @@ -87,12 +117,6 @@ 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 panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width; - const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160; - const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / MIN_CARD_WIDTH), MAX_COLUMNS)) : 1; - const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable"; - const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`); const renderPanel = ( @@ -114,7 +138,7 @@ export default function MediaMissingWidget({ {emptyLabel} ) : ( - + {entries.map(({ item, integrationId }) => ( = { thin: 52, compact: 56, comfortable: 68 }; const posterSizes: Record = { thin: 34, compact: 40, comfortable: 52 }; diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index 85b083e5d..d2fca7536 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { getQueryKey } from "@trpc/react-query"; import { @@ -43,6 +43,24 @@ const viewIcons = { statistics: IconReportAnalytics, } satisfies Record; +type TranscodingControlMode = "buttons" | "select"; + +const narrowWidthLayout = { minimumWidth: 0, advancedColumns: 1, controlMode: "select" } as const; + +const widthBreakpoints = [ + { minimumWidth: 1100, advancedColumns: 3, controlMode: "buttons" }, + { minimumWidth: 700, advancedColumns: 2, controlMode: "buttons" }, + { minimumWidth: 560, advancedColumns: 1, controlMode: "buttons" }, + narrowWidthLayout, +] as const satisfies readonly { + minimumWidth: number; + advancedColumns: number; + controlMode: TranscodingControlMode; +}[]; + +const getTranscodingWidthLayout = (width: number) => + widthBreakpoints.find((candidate) => width >= candidate.minimumWidth) ?? narrowWidthLayout; + export default function MediaTranscodingWidget({ integrationIds, options, @@ -90,6 +108,7 @@ export default function MediaTranscodingWidget({ const handleQueuePageChange = (page: number) => { setQueuePagination({ page, pageSize: queuePageSize, isAdvanced }); }; + const widthLayout = useMemo(() => getTranscodingWidthLayout(width), [width]); if (!transcodingData) return ; @@ -107,7 +126,7 @@ export default function MediaTranscodingWidget({ {queryIndicator} - = 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm"> + @@ -146,7 +165,7 @@ export default function MediaTranscodingWidget({ )} - {width >= 560 ? ( + {widthLayout.controlMode === "buttons" ? ( {views.map((value) => { const Icon = viewIcons[value]; diff --git a/packages/widgets/src/notifications/component.module.css b/packages/widgets/src/notifications/component.module.css index 04dc08932..375bb61c9 100644 --- a/packages/widgets/src/notifications/component.module.css +++ b/packages/widgets/src/notifications/component.module.css @@ -3,3 +3,8 @@ background-color: transparent; border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.38)); } + +.row { + border-bottom-style: solid; + border-bottom-width: 1px; +}