"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>; 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; 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 = { downloading: "blue", leeching: "blue", seeding: "green", completed: "green", processing: "cyan", queued: "gray", paused: "yellow", stalled: "orange", failed: "red", unknown: "gray", }; const rowBgAlpha: Partial> = { failed: "var(--mantine-color-red-light)", paused: "var(--mantine-color-yellow-light)", }; const columnVisibilityChecks: Partial 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, 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(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["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 ( {formatByteRate(speed)} ); } 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([]); const [statusFilter, setStatusFilter] = useState([]); const [contextMenu, setContextMenu] = useState(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["direction"] = "asc"; if (options.descendingDefaultSort) defaultSortDirection = "desc"; const [sortStatus, setSortStatus] = useState>(() => ({ 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(() => { 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(() => { 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(options.columns), [options.columns]); const columnContext = useMemo( () => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }), [optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size], ); let progressColumnWidth = 120; if (width < 400) progressColumnWidth = 80; const columns = useMemo((): DataTableColumn[] => { const includeColumn = (accessor: string) => shouldIncludeColumn(accessor, columnContext); const cols: (DataTableColumn | false)[] = [ includeColumn("integration") && { accessor: "integration", title: "", width: 36, render: (record) => ( ), }, includeColumn("name") && { accessor: "name", title: t("items.name.columnTitle"), sortable: true, ellipsis: true, render: (record) => ( {record.name} ), }, includeColumn("progress") && { accessor: "progress", title: t("items.progress.columnTitle"), sortable: true, width: progressColumnWidth, render: (record) => { const pct = Math.floor(record.progress * 100); return ( {`${pct}%`} ); }, }, includeColumn("size") && { accessor: "size", title: t("items.size.columnTitle"), sortable: true, width: 80, noWrap: true, render: (record) => {formatBytes(record.size)}, }, includeColumn("downSpeed") && { accessor: "downSpeed", title: t("items.downSpeed.columnTitle"), sortable: true, width: 90, noWrap: true, render: (record) => , }, includeColumn("upSpeed") && { accessor: "upSpeed", title: t("items.upSpeed.columnTitle"), sortable: true, width: 90, noWrap: true, render: (record) => , }, includeColumn("time") && { accessor: "time", title: t("items.time.columnTitle"), sortable: true, width: 100, noWrap: true, render: (record) => { if (record.time === 0) return ( ∞ ); return {dayjs().add(record.time, "milliseconds").fromNow(true)}; }, }, includeColumn("state") && { accessor: "state", title: t("items.state.columnTitle"), width: 90, render: (record) => ( {t(`states.${record.state}`)} ), }, includeColumn("added") && { accessor: "added", title: t("items.added.columnTitle"), sortable: true, width: 90, noWrap: true, render: (record) => { if (!record.added) return —; return {dayjs(record.added).fromNow()}; }, }, includeColumn("ratio") && { accessor: "ratio", title: t("items.ratio.columnTitle"), sortable: true, width: 60, render: (record) => { if (record.ratio === undefined) return null; return {formatRatio(record.ratio)}; }, }, includeColumn("received") && { accessor: "received", title: t("items.received.columnTitle"), sortable: true, width: 80, noWrap: true, render: (record) => {formatBytes(record.received)}, }, includeColumn("sent") && { accessor: "sent", title: t("items.sent.columnTitle"), sortable: true, width: 80, noWrap: true, render: (record) => { if (!record.sent) return null; return {formatBytes(record.sent)}; }, }, 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 ( {display} ); }, }, includeColumn("index") && { accessor: "index", title: t("items.index.columnTitle"), sortable: true, width: 40, render: (record) => {record.index}, }, includeColumn("type") && { accessor: "type", title: t("items.type.columnTitle"), sortable: true, width: 70, render: (record) => ( {record.type} ), }, ]; return cols.filter(Boolean) as DataTableColumn[]; }, [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 = {}; 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 (
{t("errors.noColumns")}
); } let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu; if (isEditMode) rowContextMenuHandler = undefined; return ( {(showStats || isAdvanced) && ( )} `${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 }) => , }} onScroll={() => { if (contextMenu) closeContextMenu(); }} /> {(isAdvanced || height >= 96) && ( )} {contextMenu && } ); } 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 ( {record.name} {lines.map(({ label, value }) => ( {label} {value} ))} ); } 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 ( {`${progressPercent}%`}} /> {item.name} {t(`states.${item.state}`)} {item.downSpeed !== undefined && item.downSpeed > 0 && ( } color="blue" /> )} {item.upSpeed !== undefined && item.upSpeed > 0 && ( } color="green" /> )} {item.ratio !== undefined && ( )} {item.sent !== undefined && ( )} {item.added !== undefined && ( )} {categoryDisplay && } ); } function DetailPair({ label, value, icon, color, }: { label: string; value: string; icon?: React.ReactNode; color?: string; }) { return ( {label}: {icon} {value} ); } function GlobalStatsBar({ queueStats, totalSpeed, totalUpSpeed, globalRatio, hasTorrents, clients, }: { queueStats: { stateCounts: Record; 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 ( {`${overallProgressPct}%`} {`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`} {formatByteRate(totalSpeed)} {totalUpSpeed > 0 && ( {formatByteRate(totalUpSpeed)} )} {hasTorrents && globalRatio > 0 && ( {`${t("globalRatio")}: ${globalRatio.toFixed(2)}`} )} {Object.entries(queueStats.stateCounts).map(([state, count]) => ( {count} ))} {t("stats.clients", { count: clients.length })} ); } 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 ( { e.preventDefault(); onClose(); }} > {truncateText(item.name, 40)} } onClick={() => { void navigator.clipboard.writeText(item.name).catch(() => {}); onClose(); }} > {t("contextMenu.copyName")} {item.id && ( } onClick={() => { void navigator.clipboard.writeText(item.id).catch(() => {}); onClose(); }} > {t("contextMenu.copyId")} )} {hasActions && ( <> } onClick={() => { pauseResumeInvoke?.(); onClose(); }} > {pauseResumeLabel} {!showDeleteConfirm && ( } onClick={(e) => { e.stopPropagation(); openDelete(); }} > {t("actions.item.delete.title")} )} {showDeleteConfirm && ( <> } 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; 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 ( {filterOpen && ( {clients.length > 1 && ( {t("items.integration.columnTitle")}: {clients.map(({ integration }) => { const active = clientFilter.includes(integration.id); let variant: "filled" | "light" = "light"; if (active) variant = "filled"; return ( setClientFilter(toggleArrayItem(clientFilter, integration.id))} > } > {integration.name} ); })} )} {availableStatuses.length > 1 && ( {t("items.state.columnTitle")}: {availableStatuses.map((status) => { const active = statusFilter.includes(status); let variant: "filled" | "light" = "light"; if (active) variant = "filled"; return ( setStatusFilter(toggleArrayItem(statusFilter, status))} > {t(`states.${status}`)} ); })} )} )} { if (hasActiveFilter && !filterOpen) { setClientFilter([]); setStatusFilter([]); } else { toggleFilter(); } }} > {clients.map(({ integration }) => ( ))} {!showStats && hasTorrents && globalRatio > 0 && ( {`${t("globalRatio")}: ${globalRatio.toFixed(2)}`} )} {someInteract && ( resumeQueue({ integrationIds: integrationsStatuses.paused })} > )} {!showStats && ( {formatByteRate(totalSpeed)} {totalUpSpeed > 0 && ( {formatByteRate(totalUpSpeed)} )} )} {someInteract && ( pauseQueue({ integrationIds: integrationsStatuses.active })} > )} ); } 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 ( ); }