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';
-
-
-
-
+- **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" ? : }
-
-
-
-
-
-
-
-
-
- {
- close();
- actions.delete({ fromDisk: false });
- }}
- >
- {t("actions.item.delete.entry")}
-
- {
- close();
- actions.delete({ fromDisk: true });
- }}
- leftSection={ }
- >
- {t("actions.item.delete.entryAndFiles")}
-
-
- {tCommon("action.cancel")}
-
-
-
-
- ) : (
-
-
-
- );
- },
- },
- {
- ...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 })}
- >
-
-
-
- )}
-
- {totalSpeed}
-
- {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;
+}