Files
homarr/packages/widgets/src/downloads/component.tsx
T

1047 lines
38 KiB
TypeScript

"use client";
import "../widgets-common.css";
import "./styles.css";
import { useCallback, useMemo, 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 { DataTable } from "mantine-datatable";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
dayjs.extend(relativeTime);
interface SizeConfig {
fontSize: "xs" | "sm";
iconSize: number;
cellPadding: number;
showSpeedColumns: boolean;
showTimeColumn: boolean;
showStateColumn: boolean;
}
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<ExtendedDownloadClientItem["state"], string> = {
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";
};
const rowBgAlpha: Record<ExtendedDownloadClientItem["state"], string | undefined> = {
failed: "rgba(255, 0, 0, 0.04)",
paused: "rgba(255, 200, 0, 0.03)",
downloading: undefined,
leeching: undefined,
seeding: undefined,
completed: undefined,
processing: undefined,
queued: undefined,
stalled: undefined,
unknown: undefined,
};
interface ContextMenuState {
x: number;
y: number;
item: ExtendedDownloadClientItem;
}
export default function DownloadClientsWidget({
isEditMode,
integrationIds,
options,
width,
}: WidgetComponentProps<"downloads">) {
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
integrationIds.includes(id) ? [id] : [],
);
const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
});
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 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);
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<ExtendedDownloadClientItem>>({
columnAccessor: options.defaultSort,
direction: options.descendingDefaultSort ? "desc" : "asc",
});
const data = useMemo<ExtendedDownloadClientItem[]>(() => {
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,
]);
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<ExtendedClientStatus[]>(() => {
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<ExtendedDownloadClientItem>[] => {
const cols: (DataTableColumn<ExtendedDownloadClientItem> | false)[] = [
visibleColumns.integration && {
accessor: "integration",
title: "",
width: 36,
render: (record) => (
<Tooltip label={record.integration.name} withArrow>
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
</Tooltip>
),
},
visibleColumns.name && {
accessor: "name",
title: t("items.name.columnTitle"),
sortable: true,
ellipsis: true,
render: (record) => (
<Tooltip
label={buildHoverTooltip(record, t)}
multiline
w={280}
withArrow
position="right"
openDelay={400}
transitionProps={{ transition: "fade", duration: 150 }}
>
<Group gap={6} wrap="nowrap">
<Badge size="xs" variant="dot" color={stateColorMap[record.state]} style={{ flexShrink: 0 }} />
<Text size={size.fontSize} truncate fw={500}>{record.name}</Text>
</Group>
</Tooltip>
),
},
visibleColumns.progress && {
accessor: "progress",
title: t("items.progress.columnTitle"),
sortable: true,
width: width < 400 ? 80 : 120,
render: (record) => (
<Group gap={4} wrap="nowrap" style={{ flex: 1 }}>
<Text size="xs" fw={500} w={36} ta="right" style={{ flexShrink: 0 }}>
{`${Math.floor(record.progress * 100)}%`}
</Text>
<Progress
value={Math.floor(record.progress * 100)}
color={progressColor(record.state, record.progress)}
size="sm"
style={{ flex: 1 }}
/>
</Group>
),
},
visibleColumns.size && {
accessor: "size",
title: t("items.size.columnTitle"),
sortable: true,
width: 80,
noWrap: true,
render: (record) => <Text size={size.fontSize}>{formatBytes(record.size)}</Text>,
},
visibleColumns.downSpeed && {
accessor: "downSpeed",
title: t("items.downSpeed.columnTitle"),
sortable: true,
width: 90,
noWrap: true,
render: (record) => record.downSpeed ? (
<Group gap={4} wrap="nowrap">
<IconDownload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text size={size.fontSize} c="blue">{formatByteRate(record.downSpeed)}</Text>
</Group>
) : null,
},
visibleColumns.upSpeed && {
accessor: "upSpeed",
title: t("items.upSpeed.columnTitle"),
sortable: true,
width: 90,
noWrap: true,
render: (record) => record.upSpeed ? (
<Group gap={4} wrap="nowrap">
<IconUpload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text size={size.fontSize} c="green">{formatByteRate(record.upSpeed)}</Text>
</Group>
) : null,
},
visibleColumns.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).fromNow(true)}</Text>;
},
},
visibleColumns.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>
),
},
visibleColumns.added && {
accessor: "added",
title: t("items.added.columnTitle"),
sortable: true,
width: 90,
noWrap: true,
render: (record) => <Text size={size.fontSize}>{record.added ? dayjs(record.added).fromNow() : "—"}</Text>,
},
visibleColumns.ratio && {
accessor: "ratio",
title: t("items.ratio.columnTitle"),
sortable: true,
width: 60,
render: (record) => record.ratio !== undefined ? (
<Text size={size.fontSize}>{record.ratio.toFixed(record.ratio >= 100 ? 0 : record.ratio >= 10 ? 1 : 2)}</Text>
) : null,
},
visibleColumns.received && {
accessor: "received",
title: t("items.received.columnTitle"),
sortable: true,
width: 80,
noWrap: true,
render: (record) => <Text size={size.fontSize}>{formatBytes(record.received)}</Text>,
},
visibleColumns.sent && {
accessor: "sent",
title: t("items.sent.columnTitle"),
sortable: true,
width: 80,
noWrap: true,
render: (record) => record.sent ? <Text size={size.fontSize}>{formatBytes(record.sent)}</Text> : null,
},
visibleColumns.category && {
accessor: "category",
title: t("items.category.columnTitle"),
width: 80,
ellipsis: true,
render: (record) => record.category ? (
<Text size={size.fontSize} truncate>
{Array.isArray(record.category) ? record.category.join(", ") : record.category}
</Text>
) : null,
},
visibleColumns.index && {
accessor: "index",
title: t("items.index.columnTitle"),
sortable: true,
width: 40,
render: (record) => <Text size={size.fontSize}>{record.index}</Text>,
},
visibleColumns.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>[];
}, [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 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]);
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>
);
}
return (
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
{showStats && (
<GlobalStatsBar
queueStats={queueStats}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clients={clients}
/>
)}
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
<DataTable
style={{ pointerEvents: isEditMode ? "none" : undefined }}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fz={size.fontSize}
records={sortedData}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noColumns")}
idAccessor="id"
height="100%"
className="downloads-table"
defaultColumnProps={{
noWrap: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
rowBackgroundColor={(record) => rowBgAlpha[record.state] as string | undefined}
rowExpansion={{
allowMultiple: true,
collapseProps: {
transitionDuration: 200,
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
onScroll={() => {
if (contextMenu) closeContextMenu();
}}
/>
</Box>
<WidgetFooter
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
globalRatio={globalRatio}
hasTorrents={hasTorrents}
clientFilter={clientFilter}
setClientFilter={setClientFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
availableStatuses={availableStatuses}
pauseQueue={mutatePauseQueue}
resumeQueue={mutateResumeQueue}
itemCount={sortedData.length}
showStats={showStats}
toggleStats={toggleStats}
/>
{contextMenu && (
<RowContextMenu
state={contextMenu}
onClose={closeContextMenu}
t={t}
/>
)}
</Stack>
);
}
function buildHoverTooltip(
record: ExtendedDownloadClientItem,
t: ReturnType<typeof useScopedI18n<"widget.downloads">>,
): 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: "ETA", value: dayjs().add(record.time, "milliseconds").fromNow() });
}
if (record.ratio !== undefined) {
lines.push({ label: t("items.ratio.detailsTitle"), value: record.ratio.toFixed(2) });
}
if (record.category) {
lines.push({
label: t("items.category.detailsTitle"),
value: Array.isArray(record.category) ? record.category.join(", ") : record.category,
});
}
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 progressPercent = Math.floor(item.progress * 100);
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 size="xs" ta="center" fw={700}>{`${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={item.time !== 0 ? dayjs().add(item.time, "milliseconds").fromNow() : "∞"}
/>
{item.sent !== undefined && <DetailPair label={t("items.sent.detailsTitle")} value={formatBytes(item.sent)} />}
{item.added !== undefined && <DetailPair label={t("items.added.detailsTitle")} value={dayjs(item.added).format("YYYY-MM-DD HH:mm")} />}
{item.category && (
<DetailPair
label={t("items.category.detailsTitle")}
value={Array.isArray(item.category) ? item.category.join(", ") : item.category}
/>
)}
<DetailPair label="ID" 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");
const overallProgress = queueStats.totalSize > 0 ? queueStats.completedSize / queueStats.totalSize : 0;
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={Math.floor(overallProgress * 100)} color="blue" size="xs" w={40} />
<Text size="xs" fw={600}>{`${Math.floor(overallProgress * 100)}%`}</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 ExtendedDownloadClientItem["state"]}`)} withArrow>
<Badge size="xs" variant="dot" color={stateColorMap[state as ExtendedDownloadClientItem["state"]]}>
{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: ReturnType<typeof useScopedI18n<"widget.downloads">>;
}) {
const { item } = state;
const isPaused = item.state === "paused";
const hasActions = !!item.actions;
const [showDeleteConfirm, { open: openDelete }] = useDisclosure(false);
return (
<Portal>
<Box
pos="fixed"
top={0}
left={0}
w="100vw"
h="100vh"
style={{ zIndex: 1000 }}
onClick={onClose}
onContextMenu={(e) => { e.preventDefault(); onClose(); }}
>
<Menu opened shadow="md" position="bottom-start" withinPortal={false} onClose={onClose}>
<Menu.Target>
<Box pos="fixed" style={{ left: state.x, top: state.y, width: 1, height: 1 }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{item.name.length > 40 ? `${item.name.slice(0, 40)}…` : item.name}</Menu.Label>
<Menu.Item
leftSection={<IconInfoCircle size={14} />}
onClick={() => { navigator.clipboard.writeText(item.name); onClose(); }}
>
{t("contextMenu.copyName")}
</Menu.Item>
{item.id && (
<Menu.Item
leftSection={<IconCopy size={14} />}
onClick={() => { navigator.clipboard.writeText(item.id); onClose(); }}
>
{t("contextMenu.copyId")}
</Menu.Item>
)}
{hasActions && (
<>
<Menu.Divider />
<Menu.Item
leftSection={isPaused ? <IconPlayerPlay size={14} /> : <IconPlayerPause size={14} />}
onClick={() => { (isPaused ? item.actions!.resume : item.actions!.pause)(); onClose(); }}
>
{t(`actions.item.${isPaused ? "resume" : "pause"}`)}
</Menu.Item>
{!showDeleteConfirm ? (
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={(e) => { e.stopPropagation(); openDelete(); }}
>
{t("actions.item.delete.title")}
</Menu.Item>
) : (
<>
<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;
itemCount: number;
showStats: boolean;
toggleStats: () => void;
}
function WidgetFooter({
clients,
totalSpeed,
totalUpSpeed,
globalRatio,
hasTorrents,
clientFilter,
setClientFilter,
statusFilter,
setStatusFilter,
availableStatuses,
pauseQueue,
resumeQueue,
itemCount,
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 = clients.reduce(
(acc, { status, integration: { id }, interact }) =>
status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc,
{ paused: [] as string[], active: [] as string[] },
);
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} w={50}>{t("items.integration.columnTitle")}:</Text>
<Group gap={4}>
{clients.map(({ integration }) => {
const active = clientFilter.includes(integration.id);
return (
<UnstyledButton
key={integration.id}
onClick={() =>
setClientFilter(active ? clientFilter.filter((id) => id !== integration.id) : [...clientFilter, integration.id])
}
>
<Badge
size="xs"
variant={active ? "filled" : "light"}
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} w={50}>{t("items.state.columnTitle")}:</Text>
<Group gap={4}>
{availableStatuses.map((status) => {
const active = statusFilter.includes(status);
return (
<UnstyledButton
key={status}
onClick={() =>
setStatusFilter(active ? statusFilter.filter((s) => s !== status) : [...statusFilter, status])
}
>
<Badge size="xs" variant={active ? "filled" : "light"} 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={hasActiveFilter ? t("contextMenu.clearFilters") : t("footer.filter")}>
<ActionIcon
size="xs"
variant={hasActiveFilter ? "filled" : "subtle"}
onClick={() => {
if (hasActiveFilter && !filterOpen) {
setClientFilter([]);
setStatusFilter([]);
} else {
toggleFilter();
}
}}
>
{hasActiveFilter ? <IconFilterOff size={14} /> : <IconFilter size={14} />}
</ActionIcon>
</Tooltip>
<Tooltip label={showStats ? t("stats.hide") : t("stats.show")}>
<ActionIcon
size="xs"
variant={showStats ? "filled" : "subtle"}
onClick={toggleStats}
>
{showStats ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />}
</ActionIcon>
</Tooltip>
<Group gap={2}>
{clients.map(({ integration }) => (
<ClientIndicator key={integration.id} integration={integration} />
))}
</Group>
</Group>
<Group gap={8}>
{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"
disabled={integrationsStatuses.paused.length === 0}
onClick={() => resumeQueue({ integrationIds: integrationsStatuses.paused })}
>
<IconPlayerPlay size={14} />
</ActionIcon>
</Tooltip>
)}
<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"
disabled={integrationsStatuses.active.length === 0}
onClick={() => pauseQueue({ integrationIds: integrationsStatuses.active })}
>
<IconPlayerPause size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Text size="xs" c="dimmed">{itemCount}</Text>
</Group>
</Group>
</Box>
);
}
function ClientIndicator({ integration }: { integration: ExtendedClientStatus["integration"] }) {
const isConnected = useIntegrationConnected(integration.updatedAt, { timeout: 30000 });
return (
<Tooltip label={`${integration.name}${isConnected ? "" : " (disconnected)"}`}>
<Avatar
size={18}
radius={0}
src={getIconUrl(integration.kind)}
style={{ filter: isConnected ? undefined : "grayscale(100%)", opacity: isConnected ? 1 : 0.5 }}
/>
</Tooltip>
);
}