fix: address remaining dashboard review comments

This commit is contained in:
ajnart committed 2026-08-22 15:12:02 +02:00
1 parent 1d210e930b
commit faacf66df7
5 files changed
+84 -26

No files matched your search

@@ -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(
+19 -12
View File
@@ -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<HTMLDivElement>();
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 }}
>
<div className={classes.compactScrollContent}>
<div ref={compactContentRef} className={classes.compactScrollContent}>
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
minimumItemWidth={compactLayout.minimumItemWidth}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
@@ -342,7 +349,7 @@ interface GridLayoutProps {
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
minimumItemWidth: number;
columns: number;
minimumItemHeight: number;
}
@@ -355,13 +362,13 @@ const GridLayout = ({
withBorder,
itemDirection,
hasIconColor,
minimumItemWidth,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex gap={4} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
<Flex gap={COMPACT_GRID_GAP} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
{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))`,
}}
@@ -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 <WidgetEmptyState />;
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}
</Text>
) : (
<SimpleGrid cols={Math.min(columns, entries.length)} spacing="xs" verticalSpacing="xs">
<SimpleGrid cols={Math.min(gridLayout.columns, entries.length)} spacing="xs" verticalSpacing="xs">
{entries.map(({ item, integrationId }) => (
<MediaCard
key={`${integrationId}-${item.type}-${item.id}`}
@@ -226,8 +250,6 @@ export default function MediaMissingWidget({
);
}
type Density = "thin" | "compact" | "comfortable";
const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 };
const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 };
@@ -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<View, TablerIcon>;
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 <WidgetEmptyState />;
@@ -107,7 +126,7 @@ export default function MediaTranscodingWidget({
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
</AdvancedPanel>
@@ -146,7 +165,7 @@ export default function MediaTranscodingWidget({
)}
<Divider />
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
{width >= 560 ? (
{widthLayout.controlMode === "buttons" ? (
<Button.Group style={{ flex: 1, minWidth: 0 }}>
{views.map((value) => {
const Icon = viewIcons[value];
@@ -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;
}