feat: add hover tooltips, global stats bar, enhanced row expansion, striped rows, and DataTable features
This commit is contained in:
3 files changed
+304
-37
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
iteration: 1
|
||||||
|
max_iterations: 10
|
||||||
|
status: active
|
||||||
|
goal: "Maximize the downloads widget by leveraging every mantine-datatable feature, adding hover tooltips with stats, click-to-expand rich detail rows, toggleable global stats, and comprehensive customization options"
|
||||||
|
success_criteria:
|
||||||
|
- "Leverage all mantine-datatable features: row expansion, column toggling, custom row styling, striped rows, scroll area tracking, pinned columns, and context menus"
|
||||||
|
- "Add hover tooltips on rows showing additional stats (size, speed, ETA, ratio, category)"
|
||||||
|
- "Implement click-to-expand row details with comprehensive download info"
|
||||||
|
- "Add toggleable global stats bar showing total speeds, ratios, queue counts, and disk usage"
|
||||||
|
- "Use every relevant DataTable prop from the mantine-datatable docs"
|
||||||
|
- "Typecheck passes with pnpm turbo typecheck --filter=@homarr/widgets"
|
||||||
|
- "No regressions in existing functionality (context menu, sorting, filtering still work)"
|
||||||
|
last_verdict: null
|
||||||
|
last_reason: null
|
||||||
|
last_next: null
|
||||||
|
engine: cursor
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Maximize the downloads widget by leveraging every mantine-datatable feature, adding hover tooltips with stats, click-to-expand rich detail rows, toggleable global stats, and comprehensive customization options
|
||||||
@@ -3182,6 +3182,13 @@
|
|||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"filter": "Filter downloads"
|
"filter": "Filter downloads"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"show": "Show global stats",
|
||||||
|
"hide": "Hide global stats",
|
||||||
|
"overallProgress": "Overall queue progress",
|
||||||
|
"totalSize": "Total queue size",
|
||||||
|
"clients": "{count, plural, one {# client} other {# clients}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mediaReleases": {
|
"mediaReleases": {
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Center,
|
Center,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Portal,
|
Portal,
|
||||||
Progress,
|
Progress,
|
||||||
|
RingProgress,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -21,7 +24,10 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronUp,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconDatabase,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconFilterOff,
|
IconFilterOff,
|
||||||
@@ -86,6 +92,19 @@ const progressColor = (state: ExtendedDownloadClientItem["state"], progress: num
|
|||||||
return "blue";
|
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 {
|
interface ContextMenuState {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -112,6 +131,7 @@ export default function DownloadClientsWidget({
|
|||||||
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
const [clientFilter, setClientFilter] = useState<string[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
|
const [showStats, { toggle: toggleStats }] = useDisclosure(false);
|
||||||
|
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
|
const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() };
|
||||||
@@ -253,7 +273,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: "",
|
title: "",
|
||||||
width: 36,
|
width: 36,
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<Tooltip label={record.integration.name}>
|
<Tooltip label={record.integration.name} withArrow>
|
||||||
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
|
<Avatar size={20} radius={0} src={getIconUrl(record.integration.kind)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -264,10 +284,20 @@ export default function DownloadClientsWidget({
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Tooltip
|
||||||
<Badge size="xs" variant="dot" color={stateColorMap[record.state]} style={{ flexShrink: 0 }} />
|
label={buildHoverTooltip(record, t)}
|
||||||
<Text size={size.fontSize} truncate fw={500}>{record.name}</Text>
|
multiline
|
||||||
</Group>
|
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 && {
|
visibleColumns.progress && {
|
||||||
@@ -294,6 +324,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.size.columnTitle"),
|
title: t("items.size.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 80,
|
width: 80,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => <Text size={size.fontSize}>{formatBytes(record.size)}</Text>,
|
render: (record) => <Text size={size.fontSize}>{formatBytes(record.size)}</Text>,
|
||||||
},
|
},
|
||||||
visibleColumns.downSpeed && {
|
visibleColumns.downSpeed && {
|
||||||
@@ -301,6 +332,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.downSpeed.columnTitle"),
|
title: t("items.downSpeed.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 90,
|
width: 90,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => record.downSpeed ? (
|
render: (record) => record.downSpeed ? (
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconDownload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
<IconDownload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||||
@@ -313,6 +345,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.upSpeed.columnTitle"),
|
title: t("items.upSpeed.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 90,
|
width: 90,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => record.upSpeed ? (
|
render: (record) => record.upSpeed ? (
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
<IconUpload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
<IconUpload size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||||
@@ -325,6 +358,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.time.columnTitle"),
|
title: t("items.time.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 100,
|
width: 100,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => {
|
render: (record) => {
|
||||||
if (record.time === 0) return <Text size={size.fontSize} c="dimmed">∞</Text>;
|
if (record.time === 0) return <Text size={size.fontSize} c="dimmed">∞</Text>;
|
||||||
return <Text size={size.fontSize}>{dayjs().add(record.time).fromNow(true)}</Text>;
|
return <Text size={size.fontSize}>{dayjs().add(record.time).fromNow(true)}</Text>;
|
||||||
@@ -345,6 +379,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.added.columnTitle"),
|
title: t("items.added.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 90,
|
width: 90,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => <Text size={size.fontSize}>{record.added ? dayjs(record.added).fromNow() : "—"}</Text>,
|
render: (record) => <Text size={size.fontSize}>{record.added ? dayjs(record.added).fromNow() : "—"}</Text>,
|
||||||
},
|
},
|
||||||
visibleColumns.ratio && {
|
visibleColumns.ratio && {
|
||||||
@@ -361,6 +396,7 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.received.columnTitle"),
|
title: t("items.received.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 80,
|
width: 80,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => <Text size={size.fontSize}>{formatBytes(record.received)}</Text>,
|
render: (record) => <Text size={size.fontSize}>{formatBytes(record.received)}</Text>,
|
||||||
},
|
},
|
||||||
visibleColumns.sent && {
|
visibleColumns.sent && {
|
||||||
@@ -368,12 +404,14 @@ export default function DownloadClientsWidget({
|
|||||||
title: t("items.sent.columnTitle"),
|
title: t("items.sent.columnTitle"),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: 80,
|
width: 80,
|
||||||
|
noWrap: true,
|
||||||
render: (record) => record.sent ? <Text size={size.fontSize}>{formatBytes(record.sent)}</Text> : null,
|
render: (record) => record.sent ? <Text size={size.fontSize}>{formatBytes(record.sent)}</Text> : null,
|
||||||
},
|
},
|
||||||
visibleColumns.category && {
|
visibleColumns.category && {
|
||||||
accessor: "category",
|
accessor: "category",
|
||||||
title: t("items.category.columnTitle"),
|
title: t("items.category.columnTitle"),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
ellipsis: true,
|
||||||
render: (record) => record.category ? (
|
render: (record) => record.category ? (
|
||||||
<Text size={size.fontSize} truncate>
|
<Text size={size.fontSize} truncate>
|
||||||
{Array.isArray(record.category) ? record.category.join(", ") : record.category}
|
{Array.isArray(record.category) ? record.category.join(", ") : record.category}
|
||||||
@@ -408,7 +446,6 @@ export default function DownloadClientsWidget({
|
|||||||
|
|
||||||
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
const closeContextMenu = useCallback(() => setContextMenu(null), []);
|
||||||
|
|
||||||
|
|
||||||
const totalSpeed = clients.reduce((sum, c) => sum + (c.status?.rates.down ?? 0), 0);
|
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 totalUpSpeed = clients.reduce((sum, c) => sum + (c.status?.rates.up ?? 0), 0);
|
||||||
const globalRatio = useMemo(() => {
|
const globalRatio = useMemo(() => {
|
||||||
@@ -425,6 +462,18 @@ export default function DownloadClientsWidget({
|
|||||||
return [...new Set(data.map(({ state }) => state))];
|
return [...new Set(data.map(({ state }) => state))];
|
||||||
}, [data]);
|
}, [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) {
|
if (options.columns.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
@@ -435,12 +484,27 @@ export default function DownloadClientsWidget({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
|
<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" }}>
|
<Box style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||||
<DataTable
|
<DataTable
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
withTableBorder={false}
|
withTableBorder={false}
|
||||||
borderRadius={0}
|
borderRadius={0}
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
|
striped="odd"
|
||||||
|
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||||
|
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||||
|
verticalAlign="center"
|
||||||
fz={size.fontSize}
|
fz={size.fontSize}
|
||||||
records={sortedData}
|
records={sortedData}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -450,10 +514,23 @@ export default function DownloadClientsWidget({
|
|||||||
idAccessor="id"
|
idAccessor="id"
|
||||||
height="100%"
|
height="100%"
|
||||||
className="downloads-table"
|
className="downloads-table"
|
||||||
|
defaultColumnProps={{
|
||||||
|
noWrap: true,
|
||||||
|
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
|
||||||
|
}}
|
||||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||||
|
rowBackgroundColor={(record) => rowBgAlpha[record.state] as string | undefined}
|
||||||
rowExpansion={{
|
rowExpansion={{
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
content: ({ record }) => <ExpandedRow item={record} />,
|
collapseProps: {
|
||||||
|
transitionDuration: 200,
|
||||||
|
animateOpacity: true,
|
||||||
|
transitionTimingFunction: "ease-out",
|
||||||
|
},
|
||||||
|
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
||||||
|
}}
|
||||||
|
onScroll={() => {
|
||||||
|
if (contextMenu) closeContextMenu();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -472,6 +549,8 @@ export default function DownloadClientsWidget({
|
|||||||
pauseQueue={mutatePauseQueue}
|
pauseQueue={mutatePauseQueue}
|
||||||
resumeQueue={mutateResumeQueue}
|
resumeQueue={mutateResumeQueue}
|
||||||
itemCount={sortedData.length}
|
itemCount={sortedData.length}
|
||||||
|
showStats={showStats}
|
||||||
|
toggleStats={toggleStats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
@@ -485,50 +564,189 @@ export default function DownloadClientsWidget({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExpandedRow({ item }: { item: ExtendedDownloadClientItem }) {
|
function buildHoverTooltip(
|
||||||
const t = useScopedI18n("widget.downloads");
|
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 (
|
return (
|
||||||
<Box px="sm" py="xs" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
<Stack gap={2}>
|
||||||
<Group gap="xl" wrap="wrap">
|
<Text size="xs" fw={600} style={{ wordBreak: "break-all" }}>{record.name}</Text>
|
||||||
<Group gap="xs">
|
<Divider my={2} />
|
||||||
<Avatar size={20} radius={0} src={getIconUrl(item.integration.kind)} />
|
{lines.map(({ label, value }) => (
|
||||||
<Text size="xs" fw={500}>{item.integration.name}</Text>
|
<Group key={label} gap={4} justify="space-between">
|
||||||
<Text size="xs" c="dimmed">({item.integration.kind})</Text>
|
<Text size="xs" c="dimmed">{label}</Text>
|
||||||
|
<Text size="xs" fw={500}>{value}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<DetailPair label={t("items.size.detailsTitle")} value={formatBytes(item.size)} />
|
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
|
||||||
<DetailPair label={t("items.received.detailsTitle")} value={formatBytes(item.received)} />
|
const t = useScopedI18n("widget.downloads");
|
||||||
{item.sent !== undefined && <DetailPair label={t("items.sent.detailsTitle")} value={formatBytes(item.sent)} />}
|
|
||||||
{item.downSpeed !== undefined && <DetailPair label={t("items.downSpeed.detailsTitle")} value={formatByteRate(item.downSpeed)} />}
|
const progressPercent = Math.floor(item.progress * 100);
|
||||||
{item.upSpeed !== undefined && <DetailPair label={t("items.upSpeed.detailsTitle")} value={formatByteRate(item.upSpeed)} />}
|
|
||||||
{item.ratio !== undefined && <DetailPair label={t("items.ratio.detailsTitle")} value={item.ratio.toFixed(2)} />}
|
return (
|
||||||
<DetailPair label={t("items.state.detailsTitle")} value={t(`states.${item.state}`)} />
|
<Box
|
||||||
<DetailPair
|
px="sm"
|
||||||
label={t("items.time.detailsTitle")}
|
py="xs"
|
||||||
value={item.time !== 0 ? dayjs().add(item.time, "milliseconds").fromNow() : "∞"}
|
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>}
|
||||||
/>
|
/>
|
||||||
{item.added !== undefined && <DetailPair label={t("items.added.detailsTitle")} value={dayjs(item.added).format("YYYY-MM-DD HH:mm")} />}
|
|
||||||
{item.category && (
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<DetailPair
|
<Group gap="xs" wrap="nowrap">
|
||||||
label={t("items.category.detailsTitle")}
|
<Avatar size={16} radius={0} src={getIconUrl(item.integration.kind)} />
|
||||||
value={Array.isArray(item.category) ? item.category.join(", ") : item.category}
|
<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>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailPair({ label, value }: { label: string; value: string }) {
|
function DetailPair({ label, value, icon, color }: { label: string; value: string; icon?: React.ReactNode; color?: string }) {
|
||||||
return (
|
return (
|
||||||
<Group gap={4}>
|
<Group gap={4} wrap="nowrap">
|
||||||
<Text size="xs" c="dimmed">{label}:</Text>
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", fontSize: 10 }}>{label}:</Text>
|
||||||
<Text size="xs" fw={500}>{value}</Text>
|
<Group gap={2} wrap="nowrap">
|
||||||
|
{icon}
|
||||||
|
<Text size="xs" fw={500} c={color} truncate style={{ fontSize: 10 }}>{value}</Text>
|
||||||
|
</Group>
|
||||||
</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 }: {
|
function RowContextMenu({ state, onClose, t }: {
|
||||||
state: ContextMenuState;
|
state: ContextMenuState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -537,7 +755,7 @@ function RowContextMenu({ state, onClose, t }: {
|
|||||||
const { item } = state;
|
const { item } = state;
|
||||||
const isPaused = item.state === "paused";
|
const isPaused = item.state === "paused";
|
||||||
const hasActions = !!item.actions;
|
const hasActions = !!item.actions;
|
||||||
const [showDeleteConfirm, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
const [showDeleteConfirm, { open: openDelete }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
@@ -633,6 +851,8 @@ interface WidgetFooterProps {
|
|||||||
pauseQueue: (args: { integrationIds: string[] }) => void;
|
pauseQueue: (args: { integrationIds: string[] }) => void;
|
||||||
resumeQueue: (args: { integrationIds: string[] }) => void;
|
resumeQueue: (args: { integrationIds: string[] }) => void;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
showStats: boolean;
|
||||||
|
toggleStats: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetFooter({
|
function WidgetFooter({
|
||||||
@@ -649,6 +869,8 @@ function WidgetFooter({
|
|||||||
pauseQueue,
|
pauseQueue,
|
||||||
resumeQueue,
|
resumeQueue,
|
||||||
itemCount,
|
itemCount,
|
||||||
|
showStats,
|
||||||
|
toggleStats,
|
||||||
}: WidgetFooterProps) {
|
}: WidgetFooterProps) {
|
||||||
const t = useScopedI18n("widget.downloads");
|
const t = useScopedI18n("widget.downloads");
|
||||||
const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false);
|
const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false);
|
||||||
@@ -737,6 +959,16 @@ function WidgetFooter({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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}>
|
<Group gap={2}>
|
||||||
{clients.map(({ integration }) => (
|
{clients.map(({ integration }) => (
|
||||||
<ClientIndicator key={integration.id} integration={integration} />
|
<ClientIndicator key={integration.id} integration={integration} />
|
||||||
|
|||||||
Reference in new issue
Block a user