diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 9242a84eb..1d4a24831 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -4,6 +4,7 @@ description: 'Allows you to view and manage your Downloads from both Torrent and hide_title: true --- +import { aria2Integration } from '@site/docs/integrations/aria2'; import { delugeIntegration } from '@site/docs/integrations/deluge'; import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; @@ -14,9 +15,6 @@ import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; import { downloadsWidget } from '.'; @@ -25,15 +23,18 @@ import { downloadsWidget } from '.'; categories={['Torrent client', 'Usenet client', 'Downloads']} /> -This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. +This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. -### Screenshots +### Features -import downloadsCard from './img/screenshot.png'; - -
- downloads -
+- **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 +- **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 +- **Client filtering** — filter downloads by integration or status using the footer filter panel +- **Global controls** — pause/resume all downloads across all clients from the footer bar +- **Status indicators** — color-coded badges show download state at a glance +- **Speed overview** — total download and upload speeds displayed in the footer ### Supported Integrations @@ -52,16 +53,21 @@ import downloadsCard from './img/screenshot.png'; }, { integration: qBittorentIntegration, note: "Torrent client" +}, { + integration: aria2Integration, + note: "Multi-protocol download client" }, { integration: slskdIntegration, - note: "Soulseek client" + note: "Soulseek client (read-only)" }]} /> ### Interactions -- Pause / Resume one download by clicking on the according icon. -- Delete one download by clicking on the trash icon. (Can also delete the files) -- Pause / Resume all downloads by using the bottom button. +- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions +- **Click** any row to expand it and see full download details inline +- **Pause / Resume** all downloads using the footer controls +- **Filter** by integration or status using the filter button in the footer +- **Sort** by clicking column headers (with persistent sort direction) ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.ts b/apps/docs/docs/widgets/downloads/index.ts index 4810aec89..f10e9166d 100644 --- a/apps/docs/docs/widgets/downloads/index.ts +++ b/apps/docs/docs/widgets/downloads/index.ts @@ -2,26 +2,23 @@ import { WidgetDefinition } from "@site/src/types"; import { IconDownload } from "@tabler/icons-react"; const columnsList = [ - "id", - "actions", - "added", - "category", - "downSpeed", - "index", - "integration", "name", "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", "ratio", "received", "sent", - "size", - "state", - "time", + "category", + "integration", + "index", "type", - "upSpeed", ]; -const sortingExclusion = ["actions", "id", "state"]; -const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column)); +const sortColumns = columnsList.filter((c) => !["state", "category"].includes(c)); export const downloadsWidget: WidgetDefinition = { icon: IconDownload, @@ -32,25 +29,19 @@ export const downloadsWidget: WidgetDefinition = { items: [ { name: "Columns to show", - description: "Select the columns you want to display in the widget.", + description: "Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.", values: `List of columns: ${columnsList.join(", ")}`, - defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "), + defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "), }, { - name: "Enable items sorting", - description: "Enables the sorting in the table.", - values: { type: "boolean" }, - defaultValue: "no", - }, - { - name: "Column used for sorting by default", - description: "By default it will sort by this column.", - defaultValue: "type", - values: `List of columns: ${columnsSort.join(", ")}`, + name: "Default sort column", + description: "The column used for initial sorting when the widget loads.", + defaultValue: "progress", + values: `List of columns: ${sortColumns.join(", ")}`, }, { name: "Invert sorting", - description: "This will invert the sorting order of the table.", + description: "This will invert the default sorting order.", values: { type: "boolean" }, defaultValue: "no", }, @@ -76,13 +67,13 @@ export const downloadsWidget: WidgetDefinition = { name: "Hide completed torrent under this threshold (in kiB/s)", description: "This will hide completed torrent entries that have a download speed below the specified threshold.", - values: "Any number above 1, 0 to disable", + values: "Any number above 0, 0 to disable", defaultValue: "0", }, { - name: "Categories/names to filter", - description: "You can filter the items by categories or names. Use a comma to separate multiple values.", - values: "Comma-separated list of categories or names", + name: "Categories/labels to filter", + description: "You can filter the items by categories or labels. Use a comma to separate multiple values.", + values: "Comma-separated list of categories or labels", defaultValue: "-", }, { @@ -94,13 +85,13 @@ export const downloadsWidget: WidgetDefinition = { }, { name: "Use filter to calculate Ratio", - description: "This will use the filter to calculate the ratio of items shown in the widget.", + description: "This will use the category filter when calculating the global torrent ratio.", values: { type: "boolean" }, defaultValue: "yes", }, { name: "Limit items per integration", - description: "This will limit the number of items shown per integration, not globally", + description: "This will limit the number of items shown per integration, not globally.", values: "Any number above 1", defaultValue: "50", }, diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index a52de2b04..8da3bb8a2 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -74,11 +74,10 @@ const optionMapping: OptionMapping = { categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined), filterIsWhitelist: (oldOptions) => "labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined, - enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined), showCompletedTorrent: (oldOptions) => "displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined, - columns: () => ["integration", "name", "progress", "time", "actions"], - defaultSort: () => "type", + columns: () => ["integration", "name", "progress", "time", "state"], + defaultSort: () => "progress", descendingDefaultSort: () => false, showCompletedUsenet: () => true, showCompletedHttp: () => true, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index af0ce29a6..d916f1595 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3174,7 +3174,15 @@ } } }, - "globalRatio": "Global Ratio" + "globalRatio": "Global Ratio", + "contextMenu": { + "copyName": "Copy name", + "copyId": "Copy ID", + "clearFilters": "Clear filters" + }, + "footer": { + "filter": "Filter downloads" + } }, "mediaReleases": { "name": "Media releases", diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index ff9b71664..9bc5c46c8 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -3,43 +3,45 @@ import "../widgets-common.css"; import "./styles.css"; -import { useCallback, useMemo, useState } from "react"; -import type { MantineStyleProp } from "@mantine/core"; +import { useCallback, useMemo, useRef, useState } from "react"; import { ActionIcon, Avatar, - AvatarGroup, - Button, + Badge, + Box, Center, - Chip, Divider, Group, - Modal, - Paper, - Popover, + Menu, + Portal, Progress, - Space, + ScrollArea, + SegmentedControl, Stack, Text, - Title, Tooltip, + UnstyledButton, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - IconAlertTriangle, - IconCirclesRelation, + IconChevronDown, + IconChevronRight, + IconCopy, + IconDownload, IconFilter, - IconInfinity, + IconFilterOff, IconInfoCircle, IconPlayerPause, IconPlayerPlay, IconTrash, + IconTrashX, + IconUpload, IconX, } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; -import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +import { DataTable } from "mantine-datatable"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; @@ -52,37 +54,55 @@ import type { WidgetComponentProps } from "../definition"; dayjs.extend(relativeTime); -interface QuickFilter { - integrationKinds: string[]; - statuses: ExtendedDownloadClientItem["state"][]; +interface SizeConfig { + fontSize: "xs" | "sm"; + iconSize: number; + cellPadding: number; + showSpeedColumns: boolean; + showTimeColumn: boolean; + showStateColumn: boolean; } -//Ratio table for relative width between columns -const columnsRatios: Record = { - actions: 2, - added: 4, - category: 1, - downSpeed: 3, - id: 1, - index: 1, - integration: 1, - name: 16, - progress: 6, - ratio: 2, - received: 3, - sent: 3, - size: 3, - state: 3, - time: 4, - type: 2, - upSpeed: 3, +const getSizeConfig = (width: number): SizeConfig => { + if (width < 300) { + return { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false }; + } + if (width < 500) { + return { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false }; + } + return { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; }; +const stateColorMap: Record = { + downloading: "blue", + leeching: "blue", + seeding: "green", + completed: "green", + processing: "cyan", + queued: "gray", + paused: "yellow", + stalled: "orange", + failed: "red", + unknown: "gray", +}; + +const progressColor = (state: ExtendedDownloadClientItem["state"], progress: number): string => { + if (state === "paused") return "yellow"; + if (progress >= 1) return "green"; + return "blue"; +}; + +interface ContextMenuState { + x: number; + y: number; + item: ExtendedDownloadClientItem; +} + export default function DownloadClientsWidget({ isEditMode, integrationIds, options, - setOptions, + width, }: WidgetComponentProps<"downloads">) { const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => integrationIds.includes(id) ? [id] : [], @@ -94,831 +114,713 @@ export default function DownloadClientsWidget({ }); const t = useScopedI18n("widget.downloads"); - const tCommon = useScopedI18n("common"); - //Item modal state and selection - const [clickedIndex, setClickedIndex] = useState(0); - const [opened, { open, close }] = useDisclosure(false); - - //User quick settings for filters - const [quickFilters, setQuickFilters] = useState({ integrationKinds: [], statuses: [] }); - const availableStatuses = useMemo(() => { - //Redefine list of available statuses from current items - const statuses = Array.from(new Set(currentItems.flatMap(({ data }) => data.items.map(({ state }) => state)))); - //Reset user filters accordingly to remove unavailable statuses - setQuickFilters(({ integrationKinds: names, statuses: prevStatuses }) => { - return { integrationKinds: names, statuses: prevStatuses.filter((status) => statuses.includes(status)) }; - }); - return statuses; - }, [currentItems]); + const [clientFilter, setClientFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); + const [expandedIds, setExpandedIds] = useState([]); + const [contextMenu, setContextMenu] = useState(null); const utils = clientApi.useUtils(); const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(invalidateDownloads); const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads); const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads); + const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads); + const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); - //Flatten Data array for which each element has its integration, data (base + calculated) and actions - const data = useMemo( - () => - currentItems - //Insure it is only using selected integrations - .filter(({ integration }) => integrationIds.includes(integration.id)) - //Construct normalized items list - .flatMap((pair) => - //Apply user white/black list - pair.data.items - .filter( - ({ category }) => - options.filterIsWhitelist === - options.categoryFilter.some((filter) => - (Array.isArray(category) ? category : [category]).includes(filter), - ), - ) - //Filter completed items following widget option - .filter( - ({ type, progress, upSpeed }) => - (type === "torrent" && - ((progress === 1 && - options.showCompletedTorrent && - (upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) || - progress !== 1)) || - (type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) || - (type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)), - ) - //Filter following user quick setting - .filter( - ({ state }) => - (quickFilters.integrationKinds.length === 0 || - quickFilters.integrationKinds.includes(pair.integration.name)) && - (quickFilters.statuses.length === 0 || quickFilters.statuses.includes(state)), - ) - //Add extrapolated data and actions if user is allowed interaction - .map((item): ExtendedDownloadClientItem => { - const received = item.received ?? Math.floor(item.size * item.progress); - const integrationIds = [pair.integration.id]; - return { - integration: pair.integration, - ...item, - category: item.category !== undefined && item.category.length > 0 ? item.category : undefined, - received, - ratio: item.sent !== undefined ? item.sent / item.size : undefined, - //Only add if permission to use mutations - actions: integrationsWithInteractions.includes(pair.integration.id) - ? { - resume: () => mutateResumeItem({ integrationIds, item }), - pause: () => mutatePauseItem({ integrationIds, item }), - delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds, item, fromDisk }), - } - : undefined, - }; - }), - ) - //flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent) - .sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length), - [ - currentItems, - integrationIds, - integrationsWithInteractions, - mutateDeleteItem, - mutatePauseItem, - mutateResumeItem, - options.activeTorrentThreshold, - options.categoryFilter, - options.filterIsWhitelist, - options.showCompletedTorrent, - options.showCompletedUsenet, - options.showCompletedHttp, - quickFilters, - ], - ); - - //Flatten Clients Array for which each elements has the integration and general client infos. - const clients = useMemo( - () => - currentItems - .filter(({ integration }) => integrationIds.includes(integration.id)) - .flatMap(({ integration, data }): ExtendedClientStatus => { - const interact = integrationsWithInteractions.includes(integration.id); - const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); - /** Derived from current items */ - const { totalUp, totalDown } = data.items - .filter( - ({ category }) => - !options.applyFilterToRatio || - !data.status.types.includes("torrent") || - options.filterIsWhitelist === - options.categoryFilter.some((filter) => - (Array.isArray(category) ? category : [category]).includes(filter), - ), - ) - .reduce( - ({ totalUp, totalDown }, { sent, size, progress }) => ({ - totalUp: isTorrent ? (totalUp ?? 0) + (sent ?? 0) : undefined, - totalDown: totalDown + size * progress, - }), - { totalDown: 0, totalUp: isTorrent ? 0 : undefined }, - ); - return { - integration, - interact, - status: { - totalUp, - totalDown, - ratio: totalUp === undefined ? undefined : totalUp / totalDown, - ...data.status, - }, - }; - }) - .sort( - ({ status: statusA }, { status: statusB }) => - (statusA?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity), - ), - [ - currentItems, - integrationIds, - integrationsWithInteractions, - options.applyFilterToRatio, - options.categoryFilter, - options.filterIsWhitelist, - ], - ); - - //Check existing types between torrents and usenet - const integrationTypes: ExtendedDownloadClientItem["type"][] = []; - - if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent"); - if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet"); - if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous"); - - //Set the visibility of columns depending on widget settings and available data/integrations. - const columnVisibility: MRT_VisibilityState = { - id: options.columns.includes("id"), - actions: options.columns.includes("actions") && integrationsWithInteractions.length > 0, - added: options.columns.includes("added"), - category: options.columns.includes("category"), - downSpeed: options.columns.includes("downSpeed"), - index: options.columns.includes("index"), - integration: options.columns.includes("integration") && clients.length > 1, - name: options.columns.includes("name"), - progress: options.columns.includes("progress"), - ratio: options.columns.includes("ratio") && integrationTypes.includes("torrent"), - received: options.columns.includes("received"), - sent: options.columns.includes("sent") && integrationTypes.includes("torrent"), - size: options.columns.includes("size"), - state: options.columns.includes("state"), - time: options.columns.includes("time"), - type: options.columns.includes("type") && integrationTypes.length > 1, - upSpeed: options.columns.includes("upSpeed") && integrationTypes.includes("torrent"), - } satisfies Record; - - //Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header) - const editStyle: MantineStyleProp = { - pointerEvents: isEditMode ? "none" : undefined, - }; - - //Base element in common with all columns - const columnsDefBase = useCallback( - ({ - key, - showHeader, - }: { - key: keyof ExtendedDownloadClientItem; - showHeader: boolean; - }): MRT_ColumnDef => { - return { - id: key, - accessorKey: key, - header: key, - size: columnsRatios[key], - Header: () => - showHeader ? ( - - {t(`items.${key}.columnTitle`)} - - ) : null, - }; - }, - [t], - ); - - //Make columns and cell elements, Memoized to data with deps on data and EditMode - const columns = useMemo[]>( - () => [ - { - ...columnsDefBase({ key: "actions", showHeader: false }), - enableSorting: false, - Cell: ({ cell, row }) => { - const actions = cell.getValue(); - const pausedAction = row.original.state === "paused" ? "resume" : "pause"; - const [opened, { open, close }] = useDisclosure(false); - - return actions ? ( - - - - {pausedAction === "resume" ? : } - - - - - - - - - - - - - - - - ) : ( - - - - ); - }, - }, - { - ...columnsDefBase({ key: "added", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const added = cell.getValue(); - return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; - }, - }, - { - ...columnsDefBase({ key: "category", showHeader: false }), - sortUndefined: "last", - Cell: ({ cell }) => { - const category = cell.getValue(); - return ( - category !== undefined && ( - - - - ) - ); - }, - }, - { - ...columnsDefBase({ key: "downSpeed", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const downSpeed = cell.getValue(); - return downSpeed ? {formatByteRate(downSpeed)} : null; - }, - }, - { - ...columnsDefBase({ key: "id", showHeader: false }), - enableSorting: false, - Cell: ({ cell }) => { - const id = cell.getValue(); - return ( - - - - ); - }, - }, - { - ...columnsDefBase({ key: "index", showHeader: true }), - Cell: ({ cell }) => { - const index = cell.getValue(); - return {index}; - }, - }, - { - ...columnsDefBase({ key: "integration", showHeader: false }), - Cell: ({ cell }) => { - const integration = cell.getValue(); - return ( - - - - ); - }, - }, - { - ...columnsDefBase({ key: "name", showHeader: true }), - Cell: ({ cell }) => { - const name = cell.getValue(); - return ( - - {name} - - ); - }, - }, - { - ...columnsDefBase({ key: "progress", showHeader: true }), - Cell: ({ cell, row }) => { - const progress = cell.getValue(); - return ( - - - {new Intl.NumberFormat("en", { - style: "percent", - notation: "compact", - unitDisplay: "narrow", - roundingMode: "floor", - }).format(progress)} - - - - ); - }, - }, - { - ...columnsDefBase({ key: "ratio", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const ratio = cell.getValue(); - return ratio !== undefined && {ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}; - }, - }, - { - ...columnsDefBase({ key: "received", showHeader: true }), - Cell: ({ cell }) => { - const received = cell.getValue(); - return {formatBytes(received)}; - }, - }, - { - ...columnsDefBase({ key: "sent", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const sent = cell.getValue(); - return sent && {formatBytes(sent)}; - }, - }, - { - ...columnsDefBase({ key: "size", showHeader: true }), - Cell: ({ cell }) => { - const size = cell.getValue(); - return {formatBytes(size)}; - }, - }, - { - ...columnsDefBase({ key: "state", showHeader: true }), - enableSorting: false, - Cell: ({ cell }) => { - const state = cell.getValue(); - return {t(`states.${state}`)}; - }, - }, - { - ...columnsDefBase({ key: "time", showHeader: true }), - sortingFn: (rowA, rowB) => { - const timeA = rowA.getValue("time"); - const timeB = rowB.getValue("time"); - // Map time values to canonical sort keys: - // positive = ETA in ms (sort as-is, smaller = sooner) - // 0 = stalled/infinite (sort last in ascending = Infinity) - // negative = time since completion in ms (sort first in ascending = very negative) - const toSortKey = (time: number) => (time === 0 ? Infinity : time); - return toSortKey(timeA) - toSortKey(timeB); - }, - Cell: ({ cell }) => { - const time = cell.getValue(); - return time === 0 ? : {dayjs().add(time).fromNow()}; - }, - }, - { - ...columnsDefBase({ key: "type", showHeader: true }), - Cell: ({ cell }) => { - const type = cell.getValue(); - return {type}; - }, - }, - { - ...columnsDefBase({ key: "upSpeed", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const upSpeed = cell.getValue(); - return upSpeed && {formatByteRate(upSpeed)}; - }, - }, - ], - [columnsDefBase, t, tCommon], - ); - - //Table build and config - const table = useMantineReactTable({ - columns, - data, - enablePagination: false, - enableTopToolbar: false, - enableBottomToolbar: false, - enableColumnActions: false, - enableSorting: options.enableRowSorting && !isEditMode, - enableMultiSort: true, - enableStickyHeader: false, - enableColumnOrdering: isEditMode, - enableRowVirtualization: true, - rowVirtualizerOptions: { overscan: 5 }, - mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" }, - mantineTableContainerProps: { style: { height: "100%" } }, - mantineTableProps: { - className: "downloads-widget-table", - }, - mantineTableBodyProps: { style: editStyle }, - mantineTableHeadCellProps: { - p: 4, - }, - mantineTableBodyCellProps: ({ cell, row }) => ({ - onClick: () => { - setClickedIndex(row.index); - if (cell.column.id !== "actions") open(); - }, - p: 4, - }), - onColumnOrderChange: (order) => { - //Order has a tendency to add the disabled column at the end of the the real ordered array - const columnOrder = (order as typeof options.columns).filter((column) => options.columns.includes(column)); - setOptions({ newOptions: { columns: columnOrder } }); - }, - initialState: { - sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }], - columnVisibility: { - actions: false, - added: false, - category: false, - downSpeed: false, - id: false, - index: false, - integration: false, - name: false, - progress: false, - ratio: false, - received: false, - sent: false, - size: false, - state: false, - time: false, - type: false, - upSpeed: false, - } satisfies Record, - columnOrder: options.columns, - }, - state: { - columnVisibility, - columnOrder: options.columns, - }, + const [sortStatus, setSortStatus] = useState>({ + columnAccessor: options.defaultSort, + direction: options.descendingDefaultSort ? "desc" : "asc", }); - //Used for Global Torrent Ratio - const globalTraffic = clients - .filter(({ integration: { kind } }) => - getIntegrationKindsByCategory("torrent").some((integrationKind) => integrationKind === kind), - ) - .reduce( - ({ up, down }, { status }) => ({ - up: up + (status?.totalUp ?? 0), - down: down + (status?.totalDown ?? 0), - }), - { up: 0, down: 0 }, - ); + const data = useMemo(() => { + return currentItems + .filter(({ integration }) => integrationIds.includes(integration.id)) + .flatMap((pair) => + pair.data.items + .filter(({ category }) => + options.categoryFilter.length === 0 || + options.filterIsWhitelist === + options.categoryFilter.some((filter) => + (Array.isArray(category) ? category : [category]).includes(filter), + ), + ) + .filter(({ type, progress, upSpeed }) => { + if (progress < 1) return true; + if (type === "torrent") return options.showCompletedTorrent && (upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024; + if (type === "usenet") return options.showCompletedUsenet; + return options.showCompletedHttp; + }) + .filter(({ state }) => + (clientFilter.length === 0 || clientFilter.includes(pair.integration.id)) && + (statusFilter.length === 0 || statusFilter.includes(state)), + ) + .map((item): ExtendedDownloadClientItem => { + const received = item.received ?? Math.floor(item.size * item.progress); + const ids = [pair.integration.id]; + return { + integration: pair.integration, + ...item, + category: item.category !== undefined && item.category.length > 0 ? item.category : undefined, + received, + ratio: item.sent !== undefined ? item.sent / item.size : undefined, + actions: integrationsWithInteractions.includes(pair.integration.id) + ? { + resume: () => mutateResumeItem({ integrationIds: ids, item }), + pause: () => mutatePauseItem({ integrationIds: ids, item }), + delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds: ids, item, fromDisk }), + } + : undefined, + }; + }), + ); + }, [ + currentItems, integrationIds, integrationsWithInteractions, + mutateDeleteItem, mutatePauseItem, mutateResumeItem, + options.activeTorrentThreshold, options.categoryFilter, options.filterIsWhitelist, + options.showCompletedTorrent, options.showCompletedUsenet, options.showCompletedHttp, + clientFilter, statusFilter, + ]); - if (options.columns.length === 0) + const sortedData = useMemo(() => { + const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem; + const dir = sortStatus.direction === "desc" ? -1 : 1; + return [...data].toSorted((a, b) => { + const aVal = a[accessor] ?? 0; + const bVal = b[accessor] ?? 0; + if (accessor === "time") { + const toKey = (time: number) => (time === 0 ? Infinity : time); + return (toKey(aVal as number) - toKey(bVal as number)) * dir; + } + if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir; + if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir; + return 0; + }); + }, [data, sortStatus]); + + const clients = useMemo(() => { + return currentItems + .filter(({ integration }) => integrationIds.includes(integration.id)) + .map(({ integration, data: d }): ExtendedClientStatus => { + const interact = integrationsWithInteractions.includes(integration.id); + const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); + const { totalUp, totalDown } = d.items + .filter(({ category }) => + !options.applyFilterToRatio || !d.status.types.includes("torrent") || + options.filterIsWhitelist === options.categoryFilter.some((filter) => + (Array.isArray(category) ? category : [category]).includes(filter), + ), + ) + .reduce( + ({ totalUp, totalDown }, { sent, size, progress }) => ({ + totalUp: isTorrent ? (totalUp ?? 0) + (sent ?? 0) : undefined, + totalDown: totalDown + size * progress, + }), + { totalDown: 0, totalUp: isTorrent ? 0 : undefined }, + ); + return { + integration, + interact, + status: { totalUp, totalDown, ratio: totalUp === undefined ? undefined : totalUp / totalDown, ...d.status }, + }; + }); + }, [currentItems, integrationIds, integrationsWithInteractions, options.applyFilterToRatio, options.categoryFilter, options.filterIsWhitelist]); + + const hasTorrents = data.some(({ type }) => type === "torrent"); + const hasMultipleClients = clients.length > 1; + const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1; + + const size = getSizeConfig(width); + + const visibleColumns = useMemo(() => { + const set = new Set(options.columns); + return { + name: set.has("name"), + progress: set.has("progress"), + size: set.has("size"), + downSpeed: set.has("downSpeed") && size.showSpeedColumns, + upSpeed: set.has("upSpeed") && hasTorrents && size.showSpeedColumns, + time: set.has("time") && size.showTimeColumn, + state: set.has("state") && size.showStateColumn, + added: set.has("added"), + ratio: set.has("ratio") && hasTorrents, + received: set.has("received"), + sent: set.has("sent") && hasTorrents, + category: set.has("category"), + integration: set.has("integration") && hasMultipleClients, + index: set.has("index"), + type: set.has("type") && hasMultipleTypes, + }; + }, [options.columns, size, hasTorrents, hasMultipleClients, hasMultipleTypes]); + + const columns = useMemo((): DataTableColumn[] => { + const cols: (DataTableColumn | false)[] = [ + visibleColumns.integration && { + accessor: "integration", + title: "", + width: 36, + render: (record) => ( + + + + ), + }, + visibleColumns.name && { + accessor: "name", + title: t("items.name.columnTitle"), + sortable: true, + ellipsis: true, + render: (record) => ( + + + {record.name} + + ), + }, + visibleColumns.progress && { + accessor: "progress", + title: t("items.progress.columnTitle"), + sortable: true, + width: width < 400 ? 80 : 120, + render: (record) => ( + + + {`${Math.floor(record.progress * 100)}%`} + + + + ), + }, + visibleColumns.size && { + accessor: "size", + title: t("items.size.columnTitle"), + sortable: true, + width: 80, + render: (record) => {formatBytes(record.size)}, + }, + visibleColumns.downSpeed && { + accessor: "downSpeed", + title: t("items.downSpeed.columnTitle"), + sortable: true, + width: 90, + render: (record) => record.downSpeed ? ( + + + {formatByteRate(record.downSpeed)} + + ) : null, + }, + visibleColumns.upSpeed && { + accessor: "upSpeed", + title: t("items.upSpeed.columnTitle"), + sortable: true, + width: 90, + render: (record) => record.upSpeed ? ( + + + {formatByteRate(record.upSpeed)} + + ) : null, + }, + visibleColumns.time && { + accessor: "time", + title: t("items.time.columnTitle"), + sortable: true, + width: 100, + render: (record) => { + if (record.time === 0) return ∞; + return {dayjs().add(record.time).fromNow(true)}; + }, + }, + visibleColumns.state && { + accessor: "state", + title: t("items.state.columnTitle"), + width: 90, + render: (record) => ( + + {t(`states.${record.state}`)} + + ), + }, + visibleColumns.added && { + accessor: "added", + title: t("items.added.columnTitle"), + sortable: true, + width: 90, + render: (record) => {record.added ? dayjs(record.added).fromNow() : "—"}, + }, + visibleColumns.ratio && { + accessor: "ratio", + title: t("items.ratio.columnTitle"), + sortable: true, + width: 60, + render: (record) => record.ratio !== undefined ? ( + {record.ratio.toFixed(record.ratio >= 100 ? 0 : record.ratio >= 10 ? 1 : 2)} + ) : null, + }, + visibleColumns.received && { + accessor: "received", + title: t("items.received.columnTitle"), + sortable: true, + width: 80, + render: (record) => {formatBytes(record.received)}, + }, + visibleColumns.sent && { + accessor: "sent", + title: t("items.sent.columnTitle"), + sortable: true, + width: 80, + render: (record) => record.sent ? {formatBytes(record.sent)} : null, + }, + visibleColumns.category && { + accessor: "category", + title: t("items.category.columnTitle"), + width: 80, + render: (record) => record.category ? ( + + {Array.isArray(record.category) ? record.category.join(", ") : record.category} + + ) : null, + }, + visibleColumns.index && { + accessor: "index", + title: t("items.index.columnTitle"), + sortable: true, + width: 40, + render: (record) => {record.index}, + }, + visibleColumns.type && { + accessor: "type", + title: t("items.type.columnTitle"), + sortable: true, + width: 70, + render: (record) => {record.type}, + }, + ]; + return cols.filter(Boolean) as DataTableColumn[]; + }, [visibleColumns, t, size, width]); + + const handleContextMenu = useCallback( + ({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, item: record }); + }, + [], + ); + + const closeContextMenu = useCallback(() => setContextMenu(null), []); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]); + }, []); + + const totalSpeed = clients.reduce((sum, c) => sum + (c.status?.rates.down ?? 0), 0); + const totalUpSpeed = clients.reduce((sum, c) => sum + (c.status?.rates.up ?? 0), 0); + const globalRatio = useMemo(() => { + const traffic = clients + .filter(({ integration: { kind } }) => getIntegrationKindsByCategory("torrent").some((k) => k === kind)) + .reduce( + ({ up, down }, { status }) => ({ up: up + (status?.totalUp ?? 0), down: down + (status?.totalDown ?? 0) }), + { up: 0, down: 0 }, + ); + return traffic.down > 0 ? traffic.up / traffic.down : 0; + }, [clients]); + + const availableStatuses = useMemo(() => { + return [...new Set(data.map(({ state }) => state))]; + }, [data]); + + if (options.columns.length === 0) { return (
{t("errors.noColumns")}
); + } - //The actual widget return ( - - - - {integrationTypes.includes("torrent") && ( - - {`${t("globalRatio")}:`} - {(globalTraffic.up / globalTraffic.down).toFixed(2)} - - )} - + + toggleExpand(record.id)} + rowExpansion={{ + allowMultiple: true, + expanded: { recordIds: expandedIds, onRecordIdsChange: setExpandedIds }, + content: ({ record }) => , + }} /> - - + + + + + {contextMenu && ( + + )} ); } -interface ItemInfoModalProps { - items: ExtendedDownloadClientItem[]; - currentIndex: number; - opened: boolean; - onClose: () => void; +function ExpandedRow({ item }: { item: ExtendedDownloadClientItem }) { + const t = useScopedI18n("widget.downloads"); + + return ( + + + + + {item.integration.name} + ({item.integration.kind}) + + + + + {item.sent !== undefined && } + {item.downSpeed !== undefined && } + {item.upSpeed !== undefined && } + {item.ratio !== undefined && } + + + {item.added !== undefined && } + {item.category && ( + + )} + + + ); } -const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => { - const item = useMemo(() => items[currentIndex], [items, currentIndex]); - const t = useScopedI18n("widget.downloads.states"); - //The use case for "No item found" should be impossible, hence no translation +function DetailPair({ label, value }: { label: string; value: string }) { return ( - - {item === undefined ? ( -
{"No item found"}
- ) : ( - - {item.name} - - - {`${item.integration.name} (${item.integration.kind})`} - - - - - {item.type !== "miscellaneous" && ( - - )} - - - - {item.type !== "miscellaneous" && ( - - )} - - - - - {item.type !== "miscellaneous" && } - - - - - )} -
- ); -}; - -const NormalizedLine = ({ - itemKey, - values, -}: { - itemKey: Exclude; - values?: number | string | string[]; -}) => { - const t = useScopedI18n("widget.downloads.items"); - if (typeof values !== "number" && (values === undefined || values.length === 0)) return null; - return ( - - {`${t(`${itemKey}.detailsTitle`)}:`} - {Array.isArray(values) ? ( - - {values.map((value) => ( - {value} - ))} - - ) : ( - {values} - )} + + {label}: + {value} ); -}; - -interface ClientsControlProps { - clients: ExtendedClientStatus[]; - filters: QuickFilter; - setFilters: (filters: QuickFilter) => void; - availableStatuses: QuickFilter["statuses"]; } -const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: ClientsControlProps) => { +function RowContextMenu({ state, onClose, t }: { + state: ContextMenuState; + onClose: () => void; + t: ReturnType>; +}) { + const { item } = state; + const isPaused = item.state === "paused"; + const hasActions = !!item.actions; + const [showDeleteConfirm, { open: openDelete, close: closeDelete }] = useDisclosure(false); + + return ( + + { e.preventDefault(); onClose(); }} + > + + + + + + {item.name.length > 40 ? `${item.name.slice(0, 40)}…` : item.name} + + } + onClick={() => { navigator.clipboard.writeText(item.name); onClose(); }} + > + {t("contextMenu.copyName")} + + + {item.id && ( + } + onClick={() => { navigator.clipboard.writeText(item.id); onClose(); }} + > + {t("contextMenu.copyId")} + + )} + + {hasActions && ( + <> + + : } + onClick={() => { (isPaused ? item.actions!.resume : item.actions!.pause)(); onClose(); }} + > + {t(`actions.item.${isPaused ? "resume" : "pause"}`)} + + + {!showDeleteConfirm ? ( + } + onClick={(e) => { e.stopPropagation(); openDelete(); }} + > + {t("actions.item.delete.title")} + + ) : ( + <> + } + onClick={() => { item.actions!.delete({ fromDisk: false }); onClose(); }} + > + {t("actions.item.delete.entry")} + + } + onClick={() => { item.actions!.delete({ fromDisk: true }); onClose(); }} + > + {t("actions.item.delete.entryAndFiles")} + + + )} + + )} + + + + + ); +} + +interface WidgetFooterProps { + clients: ExtendedClientStatus[]; + totalSpeed: number; + totalUpSpeed: number; + globalRatio: number; + hasTorrents: boolean; + clientFilter: string[]; + setClientFilter: (ids: string[]) => void; + statusFilter: string[]; + setStatusFilter: (statuses: string[]) => void; + availableStatuses: ExtendedDownloadClientItem["state"][]; + pauseQueue: (args: { integrationIds: string[] }) => void; + resumeQueue: (args: { integrationIds: string[] }) => void; + itemCount: number; +} + +function WidgetFooter({ + clients, + totalSpeed, + totalUpSpeed, + globalRatio, + hasTorrents, + clientFilter, + setClientFilter, + statusFilter, + setStatusFilter, + availableStatuses, + pauseQueue, + resumeQueue, + itemCount, +}: WidgetFooterProps) { + const t = useScopedI18n("widget.downloads"); + const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false); + const someInteract = clients.some(({ interact }) => interact); + const hasActiveFilter = clientFilter.length > 0 || statusFilter.length > 0; + const integrationsStatuses = clients.reduce( (acc, { status, integration: { id }, interact }) => status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc, { paused: [] as string[], active: [] as string[] }, ); - const someInteract = clients.some(({ interact }) => interact); - const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0)); - const utils = clientApi.useUtils(); - const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; - const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads); - const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); - const [opened, { open, close }] = useDisclosure(false); - const t = useScopedI18n("widget.downloads"); return ( - - - - - - - - - - {t("items.integration.columnTitle")} - setFilters({ ...filters, integrationKinds: names })} - > - {clients.map(({ integration }) => ( - - {integration.name} - - ))} - - {t("items.state.columnTitle")} - setFilters({ ...filters, statuses: statuses })} - > - {availableStatuses.map((status) => ( - - {t(`states.${status}`)} - - ))} - - - - - - {clients.map((client) => ( - - ))} - - {someInteract && ( - - mutateResumeQueue({ integrationIds: integrationsStatuses.paused })} - > - - - - )} - - {someInteract && ( - - mutatePauseQueue({ integrationIds: integrationsStatuses.active })} - > - - - - )} - - - {clients.map((client) => ( - - - - - - - {client.status ? ( - - - {client.status.rates.up !== undefined ? ( - - - {`↑ ${formatByteRate(client.status.rates.up)}`} - - {"-"} - - {formatBytes(client.status.totalUp ?? 0)} - - - ) : undefined} - - - {`↓ ${formatByteRate(client.status.rates.down)}`} - - {"-"} - - {formatBytes(Math.floor(client.status.totalDown ?? 0))} - - - - - ) : ( - - {t("errors.noCommunications")} - - )} - - - - {client.integration.name} - - - {client.status && client.interact ? ( - - { - (client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({ - integrationIds: [client.integration.id], - }); - }} - > - {client.status.paused ? : } - - - ) : ( - - - - )} + + {filterOpen && ( + + + {clients.length > 1 && ( + + {t("items.integration.columnTitle")}: + + {clients.map(({ integration }) => { + const active = clientFilter.includes(integration.id); + return ( + + setClientFilter(active ? clientFilter.filter((id) => id !== integration.id) : [...clientFilter, integration.id]) + } + > + } + > + {integration.name} + + + ); + })} + - - ))} - - - - ); -}; + )} + {availableStatuses.length > 1 && ( + + {t("items.state.columnTitle")}: + + {availableStatuses.map((status) => { + const active = statusFilter.includes(status); + return ( + + setStatusFilter(active ? statusFilter.filter((s) => s !== status) : [...statusFilter, status]) + } + > + + {t(`states.${status}`)} + + + ); + })} + + + )} + + + )} -interface ClientAvatarProps { - client: ExtendedClientStatus; + + + + { + if (hasActiveFilter && !filterOpen) { + setClientFilter([]); + setStatusFilter([]); + } else { + toggleFilter(); + } + }} + > + {hasActiveFilter ? : } + + + + + {clients.map(({ integration }) => ( + + ))} + + + + + {hasTorrents && globalRatio > 0 && ( + {`${t("globalRatio")}: ${globalRatio.toFixed(2)}`} + )} + + + {someInteract && ( + + resumeQueue({ integrationIds: integrationsStatuses.paused })} + > + + + + )} + + + + + {formatByteRate(totalSpeed)} + + {totalUpSpeed > 0 && ( + + + {formatByteRate(totalUpSpeed)} + + )} + + + {someInteract && ( + + pauseQueue({ integrationIds: integrationsStatuses.active })} + > + + + + )} + + + {itemCount} + + + + ); } -const ClientAvatar = ({ client }: ClientAvatarProps) => { - const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 }); - +function ClientIndicator({ integration }: { integration: ExtendedClientStatus["integration"] }) { + const isConnected = useIntegrationConnected(integration.updatedAt, { timeout: 30000 }); return ( - + + + ); -}; +} diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 0d9c66ddd..365cf858d 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -8,28 +8,37 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; const columnsList = [ - "id", - "actions", - "added", - "category", - "downSpeed", - "index", - "integration", "name", "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", "ratio", "received", "sent", - "size", - "state", - "time", + "category", + "integration", + "index", "type", - "upSpeed", ] as const satisfies (keyof ExtendedDownloadClientItem)[]; -const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][]; -const columnsSort = columnsList.filter((column) => - sortingExclusion.every((exclusion) => exclusion !== column), -) as Exclude; + +const sortColumns = [ + "name", + "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "added", + "ratio", + "received", + "sent", + "index", + "type", +] as const satisfies readonly (typeof columnsList)[number][]; export const { definition, componentLoader } = createWidgetDefinition("downloads", { icon: IconDownload, @@ -38,19 +47,16 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads return optionsBuilder.from( (factory) => ({ columns: factory.multiSelect({ - defaultValue: ["integration", "name", "progress", "time", "actions"], + defaultValue: ["name", "progress", "downSpeed", "time", "state"], options: columnsList.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), searchable: true, }), - enableRowSorting: factory.switch({ - defaultValue: false, - }), defaultSort: factory.select({ - defaultValue: "type", - options: columnsSort.map((value) => ({ + defaultValue: "progress", + options: sortColumns.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), @@ -68,7 +74,6 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads defaultValue: true, }), activeTorrentThreshold: factory.number({ - //in KiB/s validate: z.number().min(0), defaultValue: 0, step: 1, @@ -90,12 +95,6 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads }), }), { - defaultSort: { - shouldHide: (options) => !options.enableRowSorting, - }, - descendingDefaultSort: { - shouldHide: (options) => !options.enableRowSorting, - }, showCompletedUsenet: { shouldHide: (_, integrationKinds) => !getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)), diff --git a/packages/widgets/src/downloads/styles.css b/packages/widgets/src/downloads/styles.css index ae9bee8c1..98991c9f2 100644 --- a/packages/widgets/src/downloads/styles.css +++ b/packages/widgets/src/downloads/styles.css @@ -1,12 +1,52 @@ -/* Make the MantineReactTable transparent so the board card's opacity-controlled - background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */ -.downloads-widget-table, -.downloads-widget-table .mantine-Table-table, -.downloads-widget-table .mantine-Table-thead, -.downloads-widget-table .mantine-Table-tbody, -.downloads-widget-table .mantine-Table-tfoot, -.downloads-widget-table .mantine-Table-tr, -.downloads-widget-table th, -.downloads-widget-table td { +/* 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; +}