1413 lines
47 KiB
TypeScript
1413 lines
47 KiB
TypeScript
"use client";
|
|
|
|
import "../widgets-common.css";
|
|
import "./styles.css";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ActionIcon,
|
|
Avatar,
|
|
Badge,
|
|
Box,
|
|
Center,
|
|
Divider,
|
|
Group,
|
|
Menu,
|
|
Portal,
|
|
Progress,
|
|
RingProgress,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
} from "@mantine/core";
|
|
import { useDisclosure } from "@mantine/hooks";
|
|
import {
|
|
IconChevronDown,
|
|
IconChevronUp,
|
|
IconCopy,
|
|
IconDatabase,
|
|
IconDownload,
|
|
IconFilter,
|
|
IconFilterOff,
|
|
IconInfoCircle,
|
|
IconPlayerPause,
|
|
IconPlayerPlay,
|
|
IconTrash,
|
|
IconTrashX,
|
|
IconUpload,
|
|
} from "@tabler/icons-react";
|
|
import dayjs from "dayjs";
|
|
import relativeTime from "dayjs/plugin/relativeTime";
|
|
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
|
|
|
import { clientApi } from "@homarr/api/client";
|
|
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
|
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
|
import { useOptionalBoard } from "@homarr/boards/context";
|
|
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
|
|
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
|
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
|
|
import { showErrorNotification } from "@homarr/notifications";
|
|
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
|
|
|
import type { WidgetComponentProps } from "../definition";
|
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
|
import { HomarrDataTable } from "../common/homarr-data-table";
|
|
import { formatLocalizedDateTime } from "../common/locale";
|
|
import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout";
|
|
import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
|
|
|
|
dayjs.extend(relativeTime);
|
|
|
|
type DownloadsT = ReturnType<typeof useScopedI18n<"widget.downloads">>;
|
|
type DownloadState = ExtendedDownloadClientItem["state"];
|
|
|
|
interface SizeConfig {
|
|
fontSize: "xs" | "sm";
|
|
iconSize: number;
|
|
cellPadding: number;
|
|
showSpeedColumns: boolean;
|
|
showTimeColumn: boolean;
|
|
showStateColumn: boolean;
|
|
}
|
|
|
|
interface ContextMenuState {
|
|
x: number;
|
|
y: number;
|
|
item: ExtendedDownloadClientItem;
|
|
}
|
|
|
|
interface ColumnContext {
|
|
optionsColumnSet: Set<string>;
|
|
hasTorrents: boolean;
|
|
hasMultipleClients: boolean;
|
|
hasMultipleTypes: boolean;
|
|
size: SizeConfig;
|
|
}
|
|
|
|
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
|
|
{
|
|
maxWidth: 300,
|
|
config: {
|
|
fontSize: "xs",
|
|
iconSize: 12,
|
|
cellPadding: 2,
|
|
showSpeedColumns: false,
|
|
showTimeColumn: false,
|
|
showStateColumn: false,
|
|
},
|
|
},
|
|
{
|
|
maxWidth: 500,
|
|
config: {
|
|
fontSize: "xs",
|
|
iconSize: 14,
|
|
cellPadding: 4,
|
|
showSpeedColumns: false,
|
|
showTimeColumn: true,
|
|
showStateColumn: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
const DEFAULT_SIZE_CONFIG: SizeConfig = {
|
|
fontSize: "sm",
|
|
iconSize: 14,
|
|
cellPadding: 4,
|
|
showSpeedColumns: true,
|
|
showTimeColumn: true,
|
|
showStateColumn: true,
|
|
};
|
|
|
|
const stateColorMap: Record<DownloadState, string> = {
|
|
downloading: "blue",
|
|
leeching: "blue",
|
|
seeding: "green",
|
|
completed: "green",
|
|
processing: "cyan",
|
|
queued: "gray",
|
|
paused: "yellow",
|
|
stalled: "orange",
|
|
failed: "red",
|
|
unknown: "gray",
|
|
};
|
|
|
|
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
|
|
failed: "var(--mantine-color-red-light)",
|
|
paused: "var(--mantine-color-yellow-light)",
|
|
};
|
|
|
|
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
|
|
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
|
|
ratio: (ctx) => ctx.hasTorrents,
|
|
sent: (ctx) => ctx.hasTorrents,
|
|
integration: (ctx) => ctx.hasMultipleClients,
|
|
type: (ctx) => ctx.hasMultipleTypes,
|
|
downSpeed: (ctx) => ctx.size.showSpeedColumns,
|
|
time: (ctx) => ctx.size.showTimeColumn,
|
|
state: (ctx) => ctx.size.showStateColumn,
|
|
};
|
|
|
|
const speedColumnConfig = {
|
|
down: { Icon: IconDownload, color: "blue" as const },
|
|
up: { Icon: IconUpload, color: "green" as const },
|
|
} as const;
|
|
|
|
export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
|
|
if (isAdvanced) return DEFAULT_SIZE_CONFIG;
|
|
if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
|
|
if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
|
|
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
|
|
if (width < maxWidth) return config;
|
|
}
|
|
return DEFAULT_SIZE_CONFIG;
|
|
}
|
|
|
|
function progressColor(state: DownloadState, progress: number): string {
|
|
if (state === "paused") return "yellow";
|
|
if (progress >= 1) return "green";
|
|
return "blue";
|
|
}
|
|
|
|
const columnAccessors = [
|
|
"name",
|
|
"progress",
|
|
"size",
|
|
"downSpeed",
|
|
"upSpeed",
|
|
"time",
|
|
"state",
|
|
"added",
|
|
"ratio",
|
|
"received",
|
|
"sent",
|
|
"category",
|
|
"integration",
|
|
"index",
|
|
"type",
|
|
] as const;
|
|
|
|
function toCategoryArray(category: string | string[]): string[] {
|
|
if (Array.isArray(category)) return category;
|
|
return [category];
|
|
}
|
|
|
|
function matchesCategoryFilter(
|
|
category: string | string[] | undefined,
|
|
categoryFilter: string[],
|
|
filterIsWhitelist: boolean,
|
|
): boolean {
|
|
if (categoryFilter.length === 0) return true;
|
|
const matches = categoryFilter.some((filter) => toCategoryArray(category ?? "").includes(filter));
|
|
return filterIsWhitelist === matches;
|
|
}
|
|
|
|
function formatCategoryDisplay(category: string | string[] | undefined): string | undefined {
|
|
if (!category) return undefined;
|
|
if (Array.isArray(category)) return category.join(", ");
|
|
return category;
|
|
}
|
|
|
|
function formatRatio(ratio: number): string {
|
|
if (ratio >= 100) return ratio.toFixed(0);
|
|
if (ratio >= 10) return ratio.toFixed(1);
|
|
return ratio.toFixed(2);
|
|
}
|
|
|
|
function showCompletedItem(
|
|
item: Pick<ExtendedDownloadClientItem, "type" | "progress" | "upSpeed">,
|
|
options: WidgetComponentProps<"downloads">["options"],
|
|
): boolean {
|
|
if (item.progress < 1) return true;
|
|
if (item.type === "torrent") {
|
|
return options.showCompletedTorrent && (item.upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024;
|
|
}
|
|
if (item.type === "usenet") return options.showCompletedUsenet;
|
|
return options.showCompletedHttp;
|
|
}
|
|
|
|
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
|
|
if (!ctx.optionsColumnSet.has(accessor)) return false;
|
|
const check = columnVisibilityChecks[accessor];
|
|
if (check && !check(ctx)) return false;
|
|
return true;
|
|
}
|
|
|
|
function toggleArrayItem<T>(array: T[], value: T): T[] {
|
|
if (array.includes(value)) return array.filter((entry) => entry !== value);
|
|
return [...array, value];
|
|
}
|
|
|
|
function truncateText(text: string, maxLength: number): string {
|
|
if (text.length <= maxLength) return text;
|
|
return `${text.slice(0, maxLength)}…`;
|
|
}
|
|
|
|
function getSortMultiplier(direction: DataTableSortStatus<ExtendedDownloadClientItem>["direction"]): number {
|
|
if (direction === "desc") return -1;
|
|
return 1;
|
|
}
|
|
|
|
function compareSortValues(
|
|
accessor: keyof ExtendedDownloadClientItem,
|
|
aVal: ExtendedDownloadClientItem[keyof ExtendedDownloadClientItem],
|
|
bVal: ExtendedDownloadClientItem[keyof ExtendedDownloadClientItem],
|
|
multiplier: number,
|
|
): number {
|
|
if (accessor === "time") {
|
|
let aTime = aVal as number;
|
|
let bTime = bVal as number;
|
|
if (aTime === 0) aTime = Infinity;
|
|
if (bTime === 0) bTime = Infinity;
|
|
return (aTime - bTime) * multiplier;
|
|
}
|
|
if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * multiplier;
|
|
if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * multiplier;
|
|
return 0;
|
|
}
|
|
|
|
function buildProgressTooltip(record: ExtendedDownloadClientItem): string {
|
|
const parts = [`${formatBytes(record.received)} / ${formatBytes(record.size)}`];
|
|
if (record.downSpeed) parts.push(`↓ ${formatByteRate(record.downSpeed)}`);
|
|
if (record.time !== 0) parts.push(`ETA: ${dayjs().add(record.time, "milliseconds").fromNow(true)}`);
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
function SpeedCell({
|
|
speed,
|
|
fontSize,
|
|
direction,
|
|
}: {
|
|
speed: number | undefined;
|
|
fontSize: SizeConfig["fontSize"];
|
|
direction: keyof typeof speedColumnConfig;
|
|
}) {
|
|
if (!speed) return null;
|
|
const { Icon, color } = speedColumnConfig[direction];
|
|
return (
|
|
<Group gap={4} wrap="nowrap">
|
|
<Icon size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
|
<Text size={fontSize} c={color}>
|
|
{formatByteRate(speed)}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export default function DownloadClientsWidget({
|
|
isEditMode,
|
|
integrationIds,
|
|
options,
|
|
setOptions,
|
|
boardId,
|
|
itemId,
|
|
width,
|
|
height,
|
|
displayMode,
|
|
}: WidgetComponentProps<"downloads">) {
|
|
const board = useOptionalBoard();
|
|
const { data: session } = useSession();
|
|
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
|
const allInteractAccess = useIntegrationsWithInteractAccess();
|
|
const integrationInteractSet = useMemo(
|
|
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
|
|
[allInteractAccess, integrationIds],
|
|
);
|
|
|
|
const downloadsQuery = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
|
|
integrationIds,
|
|
limitPerIntegration: options.limitPerIntegration,
|
|
});
|
|
const currentItems = getUsableWidgetQueryData(downloadsQuery) ?? [];
|
|
const { isFetching } = downloadsQuery;
|
|
|
|
const t = useScopedI18n("widget.downloads");
|
|
|
|
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
const [showStats, { toggle: toggleStats }] = useDisclosure(false);
|
|
const isAdvanced = displayMode === "advanced";
|
|
|
|
const utils = clientApi.useUtils();
|
|
const mutationOptions = {
|
|
onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate(),
|
|
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
|
};
|
|
const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(mutationOptions);
|
|
const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(mutationOptions);
|
|
const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(mutationOptions);
|
|
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(mutationOptions);
|
|
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(mutationOptions);
|
|
|
|
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
|
|
scope: { id: `downloads-options:${boardId ?? "preview"}:${itemId ?? "preview"}` },
|
|
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
|
|
});
|
|
const persistOption = useTableLayoutPersistence({
|
|
boardId,
|
|
hasChangeAccess,
|
|
itemId,
|
|
saveItemOptions,
|
|
setOptions,
|
|
});
|
|
|
|
let defaultSortDirection: DataTableSortStatus<ExtendedDownloadClientItem>["direction"] = "asc";
|
|
if (options.descendingDefaultSort) defaultSortDirection = "desc";
|
|
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>(() => ({
|
|
columnAccessor: options.defaultSort,
|
|
direction: defaultSortDirection,
|
|
}));
|
|
const hasMountedSortDefaults = useRef(false);
|
|
useEffect(() => {
|
|
if (!hasMountedSortDefaults.current) {
|
|
hasMountedSortDefaults.current = true;
|
|
return;
|
|
}
|
|
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
|
|
}, [options.defaultSort, defaultSortDirection]);
|
|
|
|
const eligibleData = useMemo<ExtendedDownloadClientItem[]>(() => {
|
|
return currentItems
|
|
.filter(({ integration }) => integrationIds.includes(integration.id))
|
|
.flatMap((pair) =>
|
|
pair.data.items
|
|
.filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist))
|
|
.filter((item) => showCompletedItem(item, options))
|
|
.filter(() => clientFilter.length === 0 || clientFilter.includes(pair.integration.id))
|
|
.map((item): ExtendedDownloadClientItem => {
|
|
const received = item.received ?? Math.floor(item.size * item.progress);
|
|
const ids = [pair.integration.id];
|
|
let category: string | string[] | undefined = item.category;
|
|
if (category !== undefined && category.length === 0) category = undefined;
|
|
|
|
let ratio: number | undefined;
|
|
if (item.sent !== undefined && item.size > 0) ratio = item.sent / item.size;
|
|
|
|
let actions: ExtendedDownloadClientItem["actions"];
|
|
if (integrationInteractSet.has(pair.integration.id)) {
|
|
actions = {
|
|
resume: () => mutateResumeItem({ integrationIds: ids, item }),
|
|
pause: () => mutatePauseItem({ integrationIds: ids, item }),
|
|
delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds: ids, item, fromDisk }),
|
|
};
|
|
}
|
|
|
|
return { integration: pair.integration, ...item, category, received, ratio, actions };
|
|
}),
|
|
);
|
|
}, [
|
|
currentItems,
|
|
integrationIds,
|
|
integrationInteractSet,
|
|
mutateDeleteItem,
|
|
mutatePauseItem,
|
|
mutateResumeItem,
|
|
options,
|
|
clientFilter,
|
|
]);
|
|
|
|
const data = useMemo(() => filterDownloadItemsByStatus(eligibleData, statusFilter), [eligibleData, statusFilter]);
|
|
|
|
const sortedData = useMemo(() => {
|
|
const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem;
|
|
const multiplier = getSortMultiplier(sortStatus.direction);
|
|
return [...data].toSorted((a, b) => {
|
|
const aVal = a[accessor] ?? 0;
|
|
const bVal = b[accessor] ?? 0;
|
|
return compareSortValues(accessor, aVal, bVal, multiplier);
|
|
});
|
|
}, [data, sortStatus]);
|
|
|
|
const clients = useMemo<ExtendedClientStatus[]>(() => {
|
|
return currentItems
|
|
.filter(({ integration }) => integrationIds.includes(integration.id))
|
|
.map(({ integration, data: clientData }): ExtendedClientStatus => {
|
|
const interact = integrationInteractSet.has(integration.id);
|
|
const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind);
|
|
const applyRatioFilter = options.applyFilterToRatio && clientData.status.types.includes("torrent");
|
|
|
|
let initialTotalUp: number | undefined;
|
|
if (isTorrent) initialTotalUp = 0;
|
|
|
|
const { totalUp, totalDown } = clientData.items
|
|
.filter(({ category }) => {
|
|
if (!applyRatioFilter) return true;
|
|
return matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist);
|
|
})
|
|
.reduce(
|
|
(acc, { sent, size, progress }) => {
|
|
let nextTotalUp: number | undefined;
|
|
if (isTorrent) nextTotalUp = (acc.totalUp ?? 0) + (sent ?? 0);
|
|
return {
|
|
totalUp: nextTotalUp,
|
|
totalDown: acc.totalDown + size * progress,
|
|
};
|
|
},
|
|
{ totalDown: 0, totalUp: initialTotalUp },
|
|
);
|
|
|
|
let ratio: number | undefined;
|
|
if (totalUp !== undefined && totalDown !== 0) ratio = totalUp / totalDown;
|
|
|
|
return {
|
|
integration,
|
|
interact,
|
|
status: { totalUp, totalDown, ratio, ...clientData.status },
|
|
};
|
|
});
|
|
}, [
|
|
currentItems,
|
|
integrationIds,
|
|
integrationInteractSet,
|
|
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 = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
|
|
const optionsColumnSet = useMemo(() => new Set<string>(options.columns), [options.columns]);
|
|
const columnContext = useMemo<ColumnContext>(
|
|
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
|
|
[optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size],
|
|
);
|
|
|
|
let progressColumnWidth = 120;
|
|
if (width < 400) progressColumnWidth = 80;
|
|
|
|
const columns = useMemo((): DataTableColumn<ExtendedDownloadClientItem>[] => {
|
|
const includeColumn = (accessor: string) => shouldIncludeColumn(accessor, columnContext);
|
|
const cols: (DataTableColumn<ExtendedDownloadClientItem> | false)[] = [
|
|
includeColumn("integration") && {
|
|
accessor: "integration",
|
|
title: "",
|
|
width: 36,
|
|
render: (record) => (
|
|
<Tooltip key={displayMode} label={record.integration.name} withArrow>
|
|
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
|
|
</Tooltip>
|
|
),
|
|
},
|
|
includeColumn("name") && {
|
|
accessor: "name",
|
|
title: t("items.name.columnTitle"),
|
|
sortable: true,
|
|
ellipsis: true,
|
|
render: (record) => (
|
|
<Tooltip
|
|
key={displayMode}
|
|
label={buildHoverTooltip(record, t)}
|
|
multiline
|
|
w={280}
|
|
withArrow
|
|
position="bottom-start"
|
|
openDelay={400}
|
|
transitionProps={{ transition: "fade", duration: 150 }}
|
|
color="dark"
|
|
>
|
|
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
|
|
<Box
|
|
w={8}
|
|
h={8}
|
|
style={{
|
|
borderRadius: "50%",
|
|
backgroundColor: `var(--mantine-color-${stateColorMap[record.state]}-filled)`,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<Text size={size.fontSize} truncate fw={500}>
|
|
{record.name}
|
|
</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
includeColumn("progress") && {
|
|
accessor: "progress",
|
|
title: t("items.progress.columnTitle"),
|
|
sortable: true,
|
|
width: progressColumnWidth,
|
|
render: (record) => {
|
|
const pct = Math.floor(record.progress * 100);
|
|
return (
|
|
<Tooltip
|
|
key={displayMode}
|
|
label={buildProgressTooltip(record)}
|
|
withArrow
|
|
openDelay={300}
|
|
position="top"
|
|
color="dark"
|
|
>
|
|
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
|
|
<Text size="xs" fw={500} w={36} ta="right" style={{ flexShrink: 0 }}>
|
|
{`${pct}%`}
|
|
</Text>
|
|
<Progress
|
|
value={pct}
|
|
color={progressColor(record.state, record.progress)}
|
|
size="sm"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</Group>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
includeColumn("size") && {
|
|
accessor: "size",
|
|
title: t("items.size.columnTitle"),
|
|
sortable: true,
|
|
width: 80,
|
|
noWrap: true,
|
|
render: (record) => <Text size={size.fontSize}>{formatBytes(record.size)}</Text>,
|
|
},
|
|
includeColumn("downSpeed") && {
|
|
accessor: "downSpeed",
|
|
title: t("items.downSpeed.columnTitle"),
|
|
sortable: true,
|
|
width: 90,
|
|
noWrap: true,
|
|
render: (record) => <SpeedCell speed={record.downSpeed} fontSize={size.fontSize} direction="down" />,
|
|
},
|
|
includeColumn("upSpeed") && {
|
|
accessor: "upSpeed",
|
|
title: t("items.upSpeed.columnTitle"),
|
|
sortable: true,
|
|
width: 90,
|
|
noWrap: true,
|
|
render: (record) => <SpeedCell speed={record.upSpeed} fontSize={size.fontSize} direction="up" />,
|
|
},
|
|
includeColumn("time") && {
|
|
accessor: "time",
|
|
title: t("items.time.columnTitle"),
|
|
sortable: true,
|
|
width: 100,
|
|
noWrap: true,
|
|
render: (record) => {
|
|
if (record.time === 0)
|
|
return (
|
|
<Text size={size.fontSize} c="dimmed">
|
|
∞
|
|
</Text>
|
|
);
|
|
return <Text size={size.fontSize}>{dayjs().add(record.time, "milliseconds").fromNow(true)}</Text>;
|
|
},
|
|
},
|
|
includeColumn("state") && {
|
|
accessor: "state",
|
|
title: t("items.state.columnTitle"),
|
|
width: 90,
|
|
render: (record) => (
|
|
<Badge size="xs" variant="light" color={stateColorMap[record.state]}>
|
|
{t(`states.${record.state}`)}
|
|
</Badge>
|
|
),
|
|
},
|
|
includeColumn("added") && {
|
|
accessor: "added",
|
|
title: t("items.added.columnTitle"),
|
|
sortable: true,
|
|
width: 90,
|
|
noWrap: true,
|
|
render: (record) => {
|
|
if (!record.added) return <Text size={size.fontSize}>—</Text>;
|
|
return <Text size={size.fontSize}>{dayjs(record.added).fromNow()}</Text>;
|
|
},
|
|
},
|
|
includeColumn("ratio") && {
|
|
accessor: "ratio",
|
|
title: t("items.ratio.columnTitle"),
|
|
sortable: true,
|
|
width: 60,
|
|
render: (record) => {
|
|
if (record.ratio === undefined) return null;
|
|
return <Text size={size.fontSize}>{formatRatio(record.ratio)}</Text>;
|
|
},
|
|
},
|
|
includeColumn("received") && {
|
|
accessor: "received",
|
|
title: t("items.received.columnTitle"),
|
|
sortable: true,
|
|
width: 80,
|
|
noWrap: true,
|
|
render: (record) => <Text size={size.fontSize}>{formatBytes(record.received)}</Text>,
|
|
},
|
|
includeColumn("sent") && {
|
|
accessor: "sent",
|
|
title: t("items.sent.columnTitle"),
|
|
sortable: true,
|
|
width: 80,
|
|
noWrap: true,
|
|
render: (record) => {
|
|
if (!record.sent) return null;
|
|
return <Text size={size.fontSize}>{formatBytes(record.sent)}</Text>;
|
|
},
|
|
},
|
|
includeColumn("category") && {
|
|
accessor: "category",
|
|
title: t("items.category.columnTitle"),
|
|
width: 80,
|
|
ellipsis: true,
|
|
render: (record) => {
|
|
const display = formatCategoryDisplay(record.category);
|
|
if (!display) return null;
|
|
return (
|
|
<Text size={size.fontSize} truncate>
|
|
{display}
|
|
</Text>
|
|
);
|
|
},
|
|
},
|
|
includeColumn("index") && {
|
|
accessor: "index",
|
|
title: t("items.index.columnTitle"),
|
|
sortable: true,
|
|
width: 40,
|
|
render: (record) => <Text size={size.fontSize}>{record.index}</Text>,
|
|
},
|
|
includeColumn("type") && {
|
|
accessor: "type",
|
|
title: t("items.type.columnTitle"),
|
|
sortable: true,
|
|
width: 70,
|
|
render: (record) => (
|
|
<Text size={size.fontSize} tt="capitalize">
|
|
{record.type}
|
|
</Text>
|
|
),
|
|
},
|
|
];
|
|
return cols.filter(Boolean) as DataTableColumn<ExtendedDownloadClientItem>[];
|
|
}, [columnContext, t, size, progressColumnWidth, displayMode]);
|
|
|
|
const { effectiveColumns, storeKey } = usePersistedTableLayout({
|
|
columns,
|
|
columnAccessors,
|
|
columnOrder: options.columnOrder,
|
|
columnWidths: options.columnWidths,
|
|
itemId,
|
|
storeKeyPrefix: "downloads",
|
|
onLayoutChange: persistOption,
|
|
});
|
|
|
|
const handleContextMenu = useCallback(
|
|
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
|
|
event.preventDefault();
|
|
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
|
|
},
|
|
[],
|
|
);
|
|
|
|
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
|
|
|
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 },
|
|
);
|
|
if (traffic.down <= 0) return 0;
|
|
return traffic.up / traffic.down;
|
|
}, [clients]);
|
|
|
|
const availableStatuses = useMemo(() => getAvailableDownloadStates(eligibleData), [eligibleData]);
|
|
|
|
const queueStats = useMemo(() => {
|
|
const stateCounts: Record<string, number> = {};
|
|
let totalSize = 0;
|
|
let completedSize = 0;
|
|
for (const item of data) {
|
|
stateCounts[item.state] = (stateCounts[item.state] ?? 0) + 1;
|
|
totalSize += item.size;
|
|
completedSize += item.received ?? item.size * item.progress;
|
|
}
|
|
return { stateCounts, totalSize, completedSize, totalItems: data.length };
|
|
}, [data]);
|
|
|
|
if (options.columns.length === 0) {
|
|
return (
|
|
<Center h="100%">
|
|
<Text>{t("errors.noColumns")}</Text>
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
|
|
if (isEditMode) rowContextMenuHandler = undefined;
|
|
|
|
return (
|
|
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
|
{(showStats || isAdvanced) && (
|
|
<GlobalStatsBar
|
|
key={displayMode}
|
|
queueStats={queueStats}
|
|
totalSpeed={totalSpeed}
|
|
totalUpSpeed={totalUpSpeed}
|
|
globalRatio={globalRatio}
|
|
hasTorrents={hasTorrents}
|
|
clients={clients}
|
|
/>
|
|
)}
|
|
|
|
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
|
<HomarrDataTable
|
|
isEditMode={isEditMode}
|
|
cellPadding={`${size.cellPadding}px 8px`}
|
|
fetching={isFetching && currentItems.length === 0}
|
|
fz={size.fontSize}
|
|
records={sortedData}
|
|
columns={effectiveColumns}
|
|
storeColumnsKey={storeKey}
|
|
sortStatus={sortStatus}
|
|
onSortStatusChange={setSortStatus}
|
|
noRecordsText={t("errors.noItems")}
|
|
idAccessor={(record) => `${record.integration.id}:${record.id}`}
|
|
className="downloads-table"
|
|
customRowAttributes={(_, index) => ({ "data-row-index": index })}
|
|
onRowContextMenu={rowContextMenuHandler}
|
|
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
|
|
rowExpansion={{
|
|
trigger: "click",
|
|
allowMultiple: true,
|
|
collapseProps: {
|
|
transitionDuration: 200,
|
|
animateOpacity: true,
|
|
transitionTimingFunction: "ease-out",
|
|
},
|
|
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
|
}}
|
|
onScroll={() => {
|
|
if (contextMenu) closeContextMenu();
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{(isAdvanced || height >= 96) && (
|
|
<WidgetFooter
|
|
key={displayMode}
|
|
clients={clients}
|
|
totalSpeed={totalSpeed}
|
|
totalUpSpeed={totalUpSpeed}
|
|
globalRatio={globalRatio}
|
|
hasTorrents={hasTorrents}
|
|
clientFilter={clientFilter}
|
|
setClientFilter={setClientFilter}
|
|
statusFilter={statusFilter}
|
|
setStatusFilter={setStatusFilter}
|
|
availableStatuses={availableStatuses}
|
|
pauseQueue={mutatePauseQueue}
|
|
resumeQueue={mutateResumeQueue}
|
|
showStats={showStats}
|
|
toggleStats={toggleStats}
|
|
/>
|
|
)}
|
|
|
|
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): React.ReactNode {
|
|
const lines: { label: string; value: string }[] = [
|
|
{ label: t("items.size.detailsTitle"), value: `${formatBytes(record.received)} / ${formatBytes(record.size)}` },
|
|
{ label: t("items.state.detailsTitle"), value: t(`states.${record.state}`) },
|
|
];
|
|
|
|
if (record.downSpeed) {
|
|
lines.push({ label: t("items.downSpeed.detailsTitle"), value: formatByteRate(record.downSpeed) });
|
|
}
|
|
if (record.upSpeed) {
|
|
lines.push({ label: t("items.upSpeed.detailsTitle"), value: formatByteRate(record.upSpeed) });
|
|
}
|
|
if (record.time !== 0) {
|
|
lines.push({ label: t("items.time.eta"), value: dayjs().add(record.time, "milliseconds").fromNow() });
|
|
}
|
|
if (record.ratio !== undefined) {
|
|
lines.push({ label: t("items.ratio.detailsTitle"), value: record.ratio.toFixed(2) });
|
|
}
|
|
const categoryDisplay = formatCategoryDisplay(record.category);
|
|
if (categoryDisplay) {
|
|
lines.push({ label: t("items.category.detailsTitle"), value: categoryDisplay });
|
|
}
|
|
|
|
return (
|
|
<Stack gap={2}>
|
|
<Text size="xs" fw={600} style={{ wordBreak: "break-all" }}>
|
|
{record.name}
|
|
</Text>
|
|
<Divider my={2} />
|
|
{lines.map(({ label, value }) => (
|
|
<Group key={label} gap={4} justify="space-between">
|
|
<Text size="xs" c="dimmed">
|
|
{label}
|
|
</Text>
|
|
<Text size="xs" fw={500}>
|
|
{value}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
|
|
const t = useScopedI18n("widget.downloads");
|
|
const locale = useCurrentIntlLocale();
|
|
const progressPercent = Math.floor(item.progress * 100);
|
|
const categoryDisplay = formatCategoryDisplay(item.category);
|
|
|
|
let timeDisplay = "∞";
|
|
if (item.time !== 0) timeDisplay = dayjs().add(item.time, "milliseconds").fromNow();
|
|
|
|
return (
|
|
<Box
|
|
px="sm"
|
|
py="xs"
|
|
style={{ borderBottom: "1px solid var(--mantine-color-default-border)", cursor: "pointer" }}
|
|
onClick={collapse}
|
|
>
|
|
<Group gap="md" align="flex-start" wrap="nowrap">
|
|
<RingProgress
|
|
size={52}
|
|
thickness={5}
|
|
roundCaps
|
|
sections={[{ value: progressPercent, color: progressColor(item.state, item.progress) }]}
|
|
label={<Text ta="center" fw={700} style={{ fontSize: 10 }}>{`${progressPercent}%`}</Text>}
|
|
/>
|
|
|
|
<Stack gap={4} style={{ flex: 1 }}>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<Avatar size={16} radius={0} src={getIconUrl(item.integration.kind)} />
|
|
<Text size="xs" fw={600} truncate>
|
|
{item.name}
|
|
</Text>
|
|
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
|
|
{t(`states.${item.state}`)}
|
|
</Badge>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={3} spacing={4} verticalSpacing={2}>
|
|
<DetailPair
|
|
label={t("items.size.detailsTitle")}
|
|
value={`${formatBytes(item.received)} / ${formatBytes(item.size)}`}
|
|
/>
|
|
{item.downSpeed !== undefined && item.downSpeed > 0 && (
|
|
<DetailPair
|
|
label={t("items.downSpeed.detailsTitle")}
|
|
value={formatByteRate(item.downSpeed)}
|
|
icon={<IconDownload size={10} />}
|
|
color="blue"
|
|
/>
|
|
)}
|
|
{item.upSpeed !== undefined && item.upSpeed > 0 && (
|
|
<DetailPair
|
|
label={t("items.upSpeed.detailsTitle")}
|
|
value={formatByteRate(item.upSpeed)}
|
|
icon={<IconUpload size={10} />}
|
|
color="green"
|
|
/>
|
|
)}
|
|
{item.ratio !== undefined && (
|
|
<DetailPair label={t("items.ratio.detailsTitle")} value={item.ratio.toFixed(2)} />
|
|
)}
|
|
<DetailPair label={t("items.time.detailsTitle")} value={timeDisplay} />
|
|
{item.sent !== undefined && (
|
|
<DetailPair label={t("items.sent.detailsTitle")} value={formatBytes(item.sent)} />
|
|
)}
|
|
{item.added !== undefined && (
|
|
<DetailPair label={t("items.added.detailsTitle")} value={formatLocalizedDateTime(item.added, locale)} />
|
|
)}
|
|
{categoryDisplay && <DetailPair label={t("items.category.detailsTitle")} value={categoryDisplay} />}
|
|
<DetailPair label={t("items.id.detailsTitle")} value={item.id} />
|
|
</SimpleGrid>
|
|
</Stack>
|
|
</Group>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function DetailPair({
|
|
label,
|
|
value,
|
|
icon,
|
|
color,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
icon?: React.ReactNode;
|
|
color?: string;
|
|
}) {
|
|
return (
|
|
<Group gap={4} wrap="nowrap">
|
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", fontSize: 10 }}>
|
|
{label}:
|
|
</Text>
|
|
<Group gap={2} wrap="nowrap">
|
|
{icon}
|
|
<Text size="xs" fw={500} c={color} truncate style={{ fontSize: 10 }}>
|
|
{value}
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function GlobalStatsBar({
|
|
queueStats,
|
|
totalSpeed,
|
|
totalUpSpeed,
|
|
globalRatio,
|
|
hasTorrents,
|
|
clients,
|
|
}: {
|
|
queueStats: { stateCounts: Record<string, number>; totalSize: number; completedSize: number; totalItems: number };
|
|
totalSpeed: number;
|
|
totalUpSpeed: number;
|
|
globalRatio: number;
|
|
hasTorrents: boolean;
|
|
clients: ExtendedClientStatus[];
|
|
}) {
|
|
const t = useScopedI18n("widget.downloads");
|
|
|
|
let overallProgress = 0;
|
|
if (queueStats.totalSize > 0) overallProgress = queueStats.completedSize / queueStats.totalSize;
|
|
const overallProgressPct = Math.floor(overallProgress * 100);
|
|
|
|
return (
|
|
<Box px={8} py={6} style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
|
<Group justify="space-between" gap="xs">
|
|
<Group gap="md">
|
|
<Tooltip label={t("stats.overallProgress")} withArrow>
|
|
<Group gap={4}>
|
|
<Progress value={overallProgressPct} color="blue" size="xs" w={40} />
|
|
<Text size="xs" fw={600}>{`${overallProgressPct}%`}</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
|
|
<Tooltip label={t("stats.totalSize")} withArrow>
|
|
<Group gap={4}>
|
|
<IconDatabase size={12} style={{ opacity: 0.6 }} />
|
|
<Text size="xs">{`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`}</Text>
|
|
</Group>
|
|
</Tooltip>
|
|
</Group>
|
|
|
|
<Group gap="md">
|
|
<Group gap={4}>
|
|
<IconDownload size={12} style={{ opacity: 0.6 }} />
|
|
<Text size="xs" fw={600} c="blue">
|
|
{formatByteRate(totalSpeed)}
|
|
</Text>
|
|
</Group>
|
|
{totalUpSpeed > 0 && (
|
|
<Group gap={4}>
|
|
<IconUpload size={12} style={{ opacity: 0.6 }} />
|
|
<Text size="xs" fw={600} c="green">
|
|
{formatByteRate(totalUpSpeed)}
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
{hasTorrents && globalRatio > 0 && (
|
|
<Text size="xs" c="dimmed">{`${t("globalRatio")}: ${globalRatio.toFixed(2)}`}</Text>
|
|
)}
|
|
</Group>
|
|
|
|
<Group gap={6}>
|
|
{Object.entries(queueStats.stateCounts).map(([state, count]) => (
|
|
<Tooltip key={state} label={t(`states.${state as DownloadState}`)} withArrow>
|
|
<Badge size="xs" variant="dot" color={stateColorMap[state as DownloadState]}>
|
|
{count}
|
|
</Badge>
|
|
</Tooltip>
|
|
))}
|
|
<Text size="xs" c="dimmed">
|
|
{t("stats.clients", { count: clients.length })}
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClose: () => void; t: DownloadsT }) {
|
|
const { item } = state;
|
|
const isPaused = item.state === "paused";
|
|
const hasActions = !!item.actions;
|
|
const [showDeleteConfirm, { open: openDelete }] = useDisclosure(false);
|
|
|
|
let PauseResumeIcon = IconPlayerPause;
|
|
let pauseResumeInvoke = item.actions?.pause;
|
|
let pauseResumeLabel = t("actions.item.pause");
|
|
if (isPaused) {
|
|
PauseResumeIcon = IconPlayerPlay;
|
|
pauseResumeInvoke = item.actions?.resume;
|
|
pauseResumeLabel = t("actions.item.resume");
|
|
}
|
|
|
|
return (
|
|
<Portal>
|
|
<Box
|
|
pos="fixed"
|
|
top={0}
|
|
left={0}
|
|
w="100vw"
|
|
h="100vh"
|
|
style={{ zIndex: "var(--mantine-z-index-popover)" }}
|
|
onClick={onClose}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
onClose();
|
|
}}
|
|
>
|
|
<Menu
|
|
opened
|
|
shadow="md"
|
|
position="bottom-start"
|
|
withinPortal={false}
|
|
onClose={onClose}
|
|
closeOnItemClick={false}
|
|
>
|
|
<Menu.Target>
|
|
<Box pos="fixed" style={{ left: state.x, top: state.y, width: 1, height: 1 }} />
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Label>{truncateText(item.name, 40)}</Menu.Label>
|
|
|
|
<Menu.Item
|
|
leftSection={<IconInfoCircle size={14} />}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(item.name).catch(() => {});
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("contextMenu.copyName")}
|
|
</Menu.Item>
|
|
|
|
{item.id && (
|
|
<Menu.Item
|
|
leftSection={<IconCopy size={14} />}
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(item.id).catch(() => {});
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("contextMenu.copyId")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
{hasActions && (
|
|
<>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
leftSection={<PauseResumeIcon size={14} />}
|
|
onClick={() => {
|
|
pauseResumeInvoke?.();
|
|
onClose();
|
|
}}
|
|
>
|
|
{pauseResumeLabel}
|
|
</Menu.Item>
|
|
|
|
{!showDeleteConfirm && (
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<IconTrash size={14} />}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openDelete();
|
|
}}
|
|
>
|
|
{t("actions.item.delete.title")}
|
|
</Menu.Item>
|
|
)}
|
|
|
|
{showDeleteConfirm && (
|
|
<>
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<IconTrash size={14} />}
|
|
onClick={() => {
|
|
item.actions?.delete({ fromDisk: false });
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("actions.item.delete.entry")}
|
|
</Menu.Item>
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<IconTrashX size={14} />}
|
|
onClick={() => {
|
|
item.actions?.delete({ fromDisk: true });
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("actions.item.delete.entryAndFiles")}
|
|
</Menu.Item>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Box>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
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;
|
|
showStats: boolean;
|
|
toggleStats: () => void;
|
|
}
|
|
|
|
function WidgetFooter({
|
|
clients,
|
|
totalSpeed,
|
|
totalUpSpeed,
|
|
globalRatio,
|
|
hasTorrents,
|
|
clientFilter,
|
|
setClientFilter,
|
|
statusFilter,
|
|
setStatusFilter,
|
|
availableStatuses,
|
|
pauseQueue,
|
|
resumeQueue,
|
|
showStats,
|
|
toggleStats,
|
|
}: 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 = { paused: [] as string[], active: [] as string[] };
|
|
for (const {
|
|
status,
|
|
integration: { id },
|
|
interact,
|
|
} of clients) {
|
|
if (!status || !interact) continue;
|
|
if (status.paused) integrationsStatuses.paused.push(id);
|
|
else integrationsStatuses.active.push(id);
|
|
}
|
|
|
|
let filterTooltip = t("footer.filter");
|
|
if (hasActiveFilter) filterTooltip = t("contextMenu.clearFilters");
|
|
|
|
let filterIconVariant: "filled" | "subtle" = "subtle";
|
|
if (hasActiveFilter) filterIconVariant = "filled";
|
|
|
|
let statsTooltip = t("stats.show");
|
|
if (showStats) statsTooltip = t("stats.hide");
|
|
|
|
let statsIconVariant: "filled" | "subtle" = "subtle";
|
|
if (showStats) statsIconVariant = "filled";
|
|
|
|
let FilterIcon = IconFilter;
|
|
if (hasActiveFilter) FilterIcon = IconFilterOff;
|
|
|
|
let StatsIcon = IconChevronDown;
|
|
if (showStats) StatsIcon = IconChevronUp;
|
|
|
|
return (
|
|
<Box style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
|
{filterOpen && (
|
|
<Box px={8} py={6} style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
|
<Stack gap={6}>
|
|
{clients.length > 1 && (
|
|
<Group gap={4}>
|
|
<Text size="xs" fw={600} style={{ flexShrink: 0 }}>
|
|
{t("items.integration.columnTitle")}:
|
|
</Text>
|
|
<Group gap={4} wrap="wrap">
|
|
{clients.map(({ integration }) => {
|
|
const active = clientFilter.includes(integration.id);
|
|
let variant: "filled" | "light" = "light";
|
|
if (active) variant = "filled";
|
|
return (
|
|
<UnstyledButton
|
|
key={integration.id}
|
|
aria-pressed={active}
|
|
aria-label={integration.name}
|
|
onClick={() => setClientFilter(toggleArrayItem(clientFilter, integration.id))}
|
|
>
|
|
<Badge
|
|
size="xs"
|
|
variant={variant}
|
|
leftSection={<Avatar size={12} radius={0} src={getIconUrl(integration.kind)} />}
|
|
>
|
|
{integration.name}
|
|
</Badge>
|
|
</UnstyledButton>
|
|
);
|
|
})}
|
|
</Group>
|
|
</Group>
|
|
)}
|
|
{availableStatuses.length > 1 && (
|
|
<Group gap={4}>
|
|
<Text size="xs" fw={600} style={{ flexShrink: 0 }}>
|
|
{t("items.state.columnTitle")}:
|
|
</Text>
|
|
<Group gap={4} wrap="wrap">
|
|
{availableStatuses.map((status) => {
|
|
const active = statusFilter.includes(status);
|
|
let variant: "filled" | "light" = "light";
|
|
if (active) variant = "filled";
|
|
return (
|
|
<UnstyledButton
|
|
key={status}
|
|
aria-pressed={active}
|
|
aria-label={t(`states.${status}`)}
|
|
onClick={() => setStatusFilter(toggleArrayItem(statusFilter, status))}
|
|
>
|
|
<Badge size="xs" variant={variant} color={stateColorMap[status]}>
|
|
{t(`states.${status}`)}
|
|
</Badge>
|
|
</UnstyledButton>
|
|
);
|
|
})}
|
|
</Group>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
<Group px={8} py={4} justify="space-between">
|
|
<Group gap={6}>
|
|
<Tooltip label={filterTooltip}>
|
|
<ActionIcon
|
|
size="xs"
|
|
variant={filterIconVariant}
|
|
aria-label={filterTooltip}
|
|
onClick={() => {
|
|
if (hasActiveFilter && !filterOpen) {
|
|
setClientFilter([]);
|
|
setStatusFilter([]);
|
|
} else {
|
|
toggleFilter();
|
|
}
|
|
}}
|
|
>
|
|
<FilterIcon size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
|
|
<Tooltip label={statsTooltip}>
|
|
<ActionIcon size="xs" variant={statsIconVariant} aria-label={statsTooltip} onClick={toggleStats}>
|
|
<StatsIcon size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
|
|
<Group gap={2}>
|
|
{clients.map(({ integration }) => (
|
|
<ClientIndicator key={integration.id} integration={integration} />
|
|
))}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Group gap={8}>
|
|
{!showStats && hasTorrents && globalRatio > 0 && (
|
|
<Text size="xs" c="dimmed">{`${t("globalRatio")}: ${globalRatio.toFixed(2)}`}</Text>
|
|
)}
|
|
|
|
<Group gap={4}>
|
|
{someInteract && (
|
|
<Tooltip label={t("actions.clients.resume")}>
|
|
<ActionIcon
|
|
size="xs"
|
|
variant="subtle"
|
|
color="green"
|
|
aria-label={t("actions.clients.resume")}
|
|
disabled={integrationsStatuses.paused.length === 0}
|
|
onClick={() => resumeQueue({ integrationIds: integrationsStatuses.paused })}
|
|
>
|
|
<IconPlayerPlay size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{!showStats && (
|
|
<Group gap={2}>
|
|
<Text size="xs" fw={600} c="blue">
|
|
<IconDownload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
|
|
{formatByteRate(totalSpeed)}
|
|
</Text>
|
|
{totalUpSpeed > 0 && (
|
|
<Text size="xs" fw={600} c="green">
|
|
<IconUpload size={10} style={{ verticalAlign: "middle", marginRight: 2 }} />
|
|
{formatByteRate(totalUpSpeed)}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
)}
|
|
|
|
{someInteract && (
|
|
<Tooltip label={t("actions.clients.pause")}>
|
|
<ActionIcon
|
|
size="xs"
|
|
variant="subtle"
|
|
color="red"
|
|
aria-label={t("actions.clients.pause")}
|
|
disabled={integrationsStatuses.active.length === 0}
|
|
onClick={() => pauseQueue({ integrationIds: integrationsStatuses.active })}
|
|
>
|
|
<IconPlayerPause size={14} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
</Group>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function ClientIndicator({ integration }: { integration: ExtendedClientStatus["integration"] }) {
|
|
const isConnected = useIntegrationConnected(integration.updatedAt, { timeout: 30000 });
|
|
|
|
let tooltipLabel = integration.name;
|
|
const t = useScopedI18n("widget.downloads");
|
|
if (!isConnected) tooltipLabel = `${integration.name} ${t("disconnected")}`;
|
|
|
|
let avatarFilter: string | undefined;
|
|
let avatarOpacity = 1;
|
|
if (!isConnected) {
|
|
avatarFilter = "grayscale(100%)";
|
|
avatarOpacity = 0.5;
|
|
}
|
|
|
|
return (
|
|
<Tooltip label={tooltipLabel}>
|
|
<Avatar
|
|
size={18}
|
|
radius={0}
|
|
src={getIconUrl(integration.kind)}
|
|
style={{ filter: avatarFilter, opacity: avatarOpacity }}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
}
|