diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index 20c8ba52d..163d14a2f 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -23,7 +23,7 @@ Select a system row to open the complete statistics view, including system, file ### Interactions -- **Reorder columns** by dragging their headers +- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it - **Resize columns** by dragging a header's right edge - **Sort systems** by selecting a sortable column header - **Open system statistics** by selecting a row diff --git a/apps/docs/docs/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index e1224737d..05c2208a7 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -40,7 +40,7 @@ import dockerTable from './img/table.png'; ### Interactions -Drag column headers to reorder them and drag a header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. +Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options: diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 000fd066b..d73ad02bb 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -44,7 +44,7 @@ import contextMenuImg from './img/context-menu.png'; - **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes - **Sortable columns** — click any column header to sort ascending/descending -- **Column customization** — choose which columns to display via the widget options panel +- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget - **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu - **Row expansion** — click any row to see full details inline without opening a separate modal - **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index a5ad9c123..8f66ded77 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -1,11 +1,8 @@ "use client"; -import "./styles.css"; - import { useCallback, useEffect, useMemo, useState } from "react"; import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; -import { DataTable } from "mantine-datatable"; import { Activity, Battery, @@ -28,6 +25,7 @@ import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { HomarrDataTable } from "../common/homarr-data-table"; import { usePersistedTableLayout } from "../common/use-persisted-table-layout"; import type { BeszelSystemRow } from "../beszel/_shared/types"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; @@ -179,7 +177,7 @@ export default function BeszelSystemTableWidget({ ), sortable: true, render: (record) => ( - + {record.name} @@ -387,15 +385,9 @@ export default function BeszelSystemTableWidget({
- ({ padding: `${size.cellPadding}px 8px` }), - }} - scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} onRowClick={isEditMode ? undefined : handleRowClick} /> diff --git a/packages/widgets/src/beszel-system-table/styles.css b/packages/widgets/src/beszel-system-table/styles.css deleted file mode 100644 index cdfd682f9..000000000 --- a/packages/widgets/src/beszel-system-table/styles.css +++ /dev/null @@ -1,47 +0,0 @@ -/* Make the DataTable transparent so the board card's opacity-controlled - background shows through. The mantine-datatable defaults to - var(--mantine-color-body) which is opaque. */ -.beszel-table, -.beszel-table .mantine-datatable-table, -.beszel-table .mantine-datatable-table thead, -.beszel-table .mantine-datatable-table tbody, -.beszel-table .mantine-datatable-table tfoot, -.beszel-table th, -.beszel-table td { - background-color: transparent !important; -} - -.beszel-table .mantine-datatable-table tr { - background-color: transparent; -} - -.beszel-table .mantine-datatable-table tbody tr:hover { - background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; -} - -.beszel-table th { - white-space: nowrap; - background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; - backdrop-filter: blur(8px); -} - -.beszel-table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.beszel-table td .mantine-Text-root, -.beszel-table td .mantine-Group-root { - white-space: nowrap; - flex-wrap: nowrap; -} - -.beszel-table .mantine-datatable-header-cell-sortable-icon { - opacity: 0; - transition: opacity 150ms; -} - -.beszel-table th:hover .mantine-datatable-header-cell-sortable-icon { - opacity: 1; -} diff --git a/packages/widgets/src/common/homarr-data-table.css b/packages/widgets/src/common/homarr-data-table.css new file mode 100644 index 000000000..0e9d7d7c5 --- /dev/null +++ b/packages/widgets/src/common/homarr-data-table.css @@ -0,0 +1,52 @@ +.homarr-data-table, +.homarr-data-table .mantine-datatable-table, +.homarr-data-table .mantine-datatable-table thead, +.homarr-data-table .mantine-datatable-table tbody, +.homarr-data-table .mantine-datatable-table tfoot, +.homarr-data-table th, +.homarr-data-table td { + background-color: transparent !important; +} + +.homarr-data-table .mantine-datatable-table tr { + background-color: transparent; +} + +.homarr-data-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; +} + +.homarr-data-table th { + white-space: nowrap; + background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + backdrop-filter: blur(8px); +} + +.homarr-data-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.homarr-data-table td .mantine-Text-root, +.homarr-data-table td .mantine-Group-root { + white-space: nowrap; + flex-wrap: nowrap; +} + +.homarr-data-table .mantine-datatable-header-cell-sortable-icon, +.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon { + opacity: 0; + transition: opacity 150ms ease-out; +} + +.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon, +.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon, +.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible, +.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon { + opacity: 1; +} + +.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor { + cursor: default; +} diff --git a/packages/widgets/src/common/homarr-data-table.tsx b/packages/widgets/src/common/homarr-data-table.tsx new file mode 100644 index 000000000..0941a9305 --- /dev/null +++ b/packages/widgets/src/common/homarr-data-table.tsx @@ -0,0 +1,57 @@ +"use client"; + +import "./homarr-data-table.css"; + +import type { DataTableProps } from "mantine-datatable"; +import { DataTable } from "mantine-datatable"; + +type HomarrDataTableProps = DataTableProps & { + isEditMode: boolean; + cellPadding: string; + rowCursor?: "auto" | "default"; +}; + +export function HomarrDataTable({ + isEditMode, + cellPadding, + rowCursor = "auto", + className, + defaultColumnProps, + scrollAreaProps, + style, + ...props +}: HomarrDataTableProps) { + const mergedClassName = [ + "homarr-data-table", + rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined, + className, + ] + .filter(Boolean) + .join(" "); + + return ( + + withTableBorder={false} + borderRadius={0} + highlightOnHover + striped="odd" + stripedColor={{ dark: "dark.7", light: "gray.0" }} + highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }} + verticalAlign="center" + loaderBackgroundBlur={2} + textSelectionDisabled + height="100%" + {...props} + className={mergedClassName} + style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]} + defaultColumnProps={{ + ...defaultColumnProps, + noWrap: defaultColumnProps?.noWrap ?? true, + draggable: !isEditMode, + resizable: !isEditMode, + cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }} + /> + ); +} diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 9423639e4..97cac81a3 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -1,9 +1,6 @@ "use client"; -import "./styles.css"; - import { useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; import { ActionIcon, Avatar, Badge, Box, Center, Group, Menu, Portal, Stack, Text, Tooltip } from "@mantine/core"; import { IconBrandDocker, @@ -17,7 +14,6 @@ import { IconTrash, } from "@tabler/icons-react"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; -import { DataTable } from "mantine-datatable"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -33,6 +29,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { HomarrDataTable } from "../common/homarr-data-table"; import { usePersistedTableLayout } from "../common/use-persisted-table-layout"; type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; @@ -51,6 +48,7 @@ interface ContainerActionHandlers { } const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const; +const containerMenuWidth = 240; const createContainerLogsPath = (container: Pick) => `/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`; @@ -196,7 +194,6 @@ export default function DockerWidget({ }: WidgetComponentProps<"dockerContainers">) { const t = useScopedI18n("docker"); const tWidget = useScopedI18n("widget.dockerContainers"); - const router = useRouter(); const { openModal } = useModalAction(AddDockerAppToHomarr); const board = useOptionalBoard(); const { data: session } = useSession(); @@ -222,8 +219,8 @@ export default function DockerWidget({ [removeContainer, restartContainer, startContainer, stopContainer], ); const handleOpenLogs = useCallback( - (container: DockerContainer) => router.push(createContainerLogsPath(container)), - [router], + (container: DockerContainer) => window.location.assign(createContainerLogsPath(container)), + [], ); const handleAddToHomarr = useCallback( (container: DockerContainer) => openModal({ selectedContainers: [container] }), @@ -317,35 +314,19 @@ export default function DockerWidget({ return ( - ({ padding: width < 400 ? "2px 8px" : "4px 8px" }), - }} - scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} onRowContextMenu={isEditMode ? undefined : handleContextMenu} onScroll={() => { if (contextMenu) closeContextMenu(); @@ -414,7 +395,7 @@ function ContainerMenuButton({ - + @@ -442,7 +423,9 @@ function ContainerActionItems({ return ( <> - {container.name} + + {container.name} + } onClick={() => { @@ -521,7 +504,12 @@ function ContainerContextMenu({ - event.stopPropagation()}> + event.stopPropagation()} + > diff --git a/packages/widgets/src/docker/styles.css b/packages/widgets/src/docker/styles.css deleted file mode 100644 index 631575dfc..000000000 --- a/packages/widgets/src/docker/styles.css +++ /dev/null @@ -1,45 +0,0 @@ -/* Keep the table transparent so the board card controls the surface. */ -.docker-containers-table, -.docker-containers-table .mantine-datatable-table, -.docker-containers-table .mantine-datatable-table thead, -.docker-containers-table .mantine-datatable-table tbody, -.docker-containers-table .mantine-datatable-table tfoot, -.docker-containers-table th, -.docker-containers-table td { - background-color: transparent !important; -} - -.docker-containers-table .mantine-datatable-table tr { - background-color: transparent; -} - -.docker-containers-table .mantine-datatable-table tbody tr:hover { - background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; -} - -.docker-containers-table th { - white-space: nowrap; - background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; - backdrop-filter: blur(8px); -} - -.docker-containers-table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.docker-containers-table td .mantine-Text-root, -.docker-containers-table td .mantine-Group-root { - white-space: nowrap; - flex-wrap: nowrap; -} - -.docker-containers-table .mantine-datatable-header-cell-sortable-icon { - opacity: 0; - transition: opacity 150ms; -} - -.docker-containers-table th:hover .mantine-datatable-header-cell-sortable-icon { - opacity: 1; -} diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index e03c937bf..7b38a5a8d 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -3,7 +3,7 @@ import "../widgets-common.css"; import "./styles.css"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ActionIcon, Avatar, @@ -41,10 +41,11 @@ import { import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; -import { DataTable, useDataTableColumns } from "mantine-datatable"; import { clientApi } from "@homarr/api/client"; -import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; +import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client"; +import { constructBoardPermissions } from "@homarr/auth/shared"; +import { useOptionalBoard } from "@homarr/boards/context"; import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; @@ -52,6 +53,8 @@ import { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { HomarrDataTable } from "../common/homarr-data-table"; +import { usePersistedTableLayout } from "../common/use-persisted-table-layout"; dayjs.extend(relativeTime); @@ -162,14 +165,23 @@ function progressColor(state: DownloadState, progress: number): string { return "blue"; } -function parseJsonOption(value: string | undefined, fallback: T): T { - if (!value) return fallback; - try { - return JSON.parse(value) as T; - } catch { - return fallback; - } -} +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; @@ -288,6 +300,9 @@ export default function DownloadClientsWidget({ width, }: WidgetComponentProps<"downloads">) { const allInteractAccess = useIntegrationsWithInteractAccess(); + const board = useOptionalBoard(); + const { data: session } = useSession(); + const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const integrationInteractSet = useMemo( () => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)), [allInteractAccess, integrationIds], @@ -324,17 +339,11 @@ export default function DownloadClientsWidget({ onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), }); const persistOption = useCallback( - (newOptions: Partial>) => { + (newOptions: Partial>) => { setOptions({ newOptions }); - if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions }); + if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions }); }, - [setOptions, boardId, itemId, saveItemOptions], - ); - - const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]); - const savedWidths = useMemo( - () => parseJsonOption(options.columnWidths, {} as Record), - [options.columnWidths], + [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions], ); let defaultSortDirection: DataTableSortStatus["direction"] = "asc"; @@ -659,54 +668,15 @@ export default function DownloadClientsWidget({ return cols.filter(Boolean) as DataTableColumn[]; }, [columnContext, t, size, progressColumnWidth]); - const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; - const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = - useDataTableColumns({ - key: storeKey, - columns, - }); - - const lastStoreKey = useRef(storeKey); - const hydrated = useRef(false); - useEffect(() => { - if (lastStoreKey.current !== storeKey) { - lastStoreKey.current = storeKey; - hydrated.current = false; - } - if (hydrated.current) return; - if (savedOrder.length > 0) setColumnsOrder(savedOrder); - if (Object.keys(savedWidths).length > 0) { - setMultipleColumnWidths(Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w }))); - } - // ponytail: defer hydrated flag so the persist effect skips the restore echo - requestAnimationFrame(() => { - hydrated.current = true; - }); - }, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); - - const prevOrder = useRef(columnsOrder); - const prevWidths = useRef(columnsWidth); - useEffect(() => { - if (!hydrated.current) return; - const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); - const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); - prevOrder.current = columnsOrder; - prevWidths.current = columnsWidth; - - if (!orderChanged && !widthsChanged) return; - if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); - if (widthsChanged) { - const widthMap: Record = {}; - for (const entry of columnsWidth) { - const key = Object.keys(entry)[0]; - if (!key) continue; - const w = entry[key as keyof typeof entry]; - if (typeof w === "number") widthMap[key] = w; - else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); - } - persistOption({ columnWidths: JSON.stringify(widthMap) }); - } - }, [columnsOrder, columnsWidth, persistOption]); + const { effectiveColumns, storeKey } = usePersistedTableLayout({ + columns, + columnAccessors, + columnOrder: options.columnOrder, + columnWidths: options.columnWidths, + itemId, + storeKeyPrefix: "downloads", + onLayoutChange: persistOption, + }); const handleContextMenu = useCallback( ({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => { @@ -761,9 +731,6 @@ export default function DownloadClientsWidget({ ); } - let tablePointerEvents: React.CSSProperties["pointerEvents"]; - if (isEditMode) tablePointerEvents = "none"; - let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu; if (isEditMode) rowContextMenuHandler = undefined; @@ -781,35 +748,19 @@ export default function DownloadClientsWidget({ )} - `${record.integration.id}:${record.id}`} - height="100%" className="downloads-table" - defaultColumnProps={{ - noWrap: true, - draggable: true, - resizable: true, - cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), - }} - scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} customRowAttributes={(_, index) => ({ "data-row-index": index })} onRowContextMenu={rowContextMenuHandler} rowBackgroundColor={(record) => rowBgAlpha[record.state]} diff --git a/packages/widgets/src/downloads/styles.css b/packages/widgets/src/downloads/styles.css index 98991c9f2..0caffa099 100644 --- a/packages/widgets/src/downloads/styles.css +++ b/packages/widgets/src/downloads/styles.css @@ -1,51 +1,3 @@ -/* Make the DataTable transparent so the board card's opacity-controlled - background shows through. The mantine-datatable defaults to - var(--mantine-color-body) which is opaque. */ -.downloads-table, -.downloads-table .mantine-datatable-table, -.downloads-table .mantine-datatable-table thead, -.downloads-table .mantine-datatable-table tbody, -.downloads-table .mantine-datatable-table tfoot, -.downloads-table th, -.downloads-table td { - background-color: transparent !important; -} - -.downloads-table .mantine-datatable-table tr { - background-color: transparent; -} - -.downloads-table .mantine-datatable-table tbody tr:hover { - background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; -} - -.downloads-table th { - white-space: nowrap; - background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; - backdrop-filter: blur(8px); -} - -.downloads-table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.downloads-table td .mantine-Text-root, -.downloads-table td .mantine-Group-root { - white-space: nowrap; - flex-wrap: nowrap; -} - -.downloads-table .mantine-datatable-header-cell-sortable-icon { - opacity: 0; - transition: opacity 150ms; -} - -.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon { - opacity: 1; -} - /* Row expansion styling */ .downloads-table .mantine-datatable-row-expansion-cell { background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;