diff --git a/.cursor/goal/scratchpad.md b/.cursor/goal/scratchpad.md new file mode 100644 index 000000000..5ad5b7d84 --- /dev/null +++ b/.cursor/goal/scratchpad.md @@ -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 diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index d916f1595..00b39ab51 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3182,6 +3182,13 @@ }, "footer": { "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": { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 9971d6c5f..09ff17b92 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -10,10 +10,13 @@ import { Badge, Box, Center, + Divider, Group, Menu, Portal, Progress, + RingProgress, + SimpleGrid, Stack, Text, Tooltip, @@ -21,7 +24,10 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { + IconChevronDown, + IconChevronUp, IconCopy, + IconDatabase, IconDownload, IconFilter, IconFilterOff, @@ -86,6 +92,19 @@ const progressColor = (state: ExtendedDownloadClientItem["state"], progress: num return "blue"; }; +const rowBgAlpha: Record = { + 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; @@ -112,6 +131,7 @@ export default function DownloadClientsWidget({ const [clientFilter, setClientFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); const [contextMenu, setContextMenu] = useState(null); + const [showStats, { toggle: toggleStats }] = useDisclosure(false); const utils = clientApi.useUtils(); const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; @@ -253,7 +273,7 @@ export default function DownloadClientsWidget({ title: "", width: 36, render: (record) => ( - + ), @@ -264,10 +284,20 @@ export default function DownloadClientsWidget({ sortable: true, ellipsis: true, render: (record) => ( - - - {record.name} - + + + + {record.name} + + ), }, visibleColumns.progress && { @@ -294,6 +324,7 @@ export default function DownloadClientsWidget({ title: t("items.size.columnTitle"), sortable: true, width: 80, + noWrap: true, render: (record) => {formatBytes(record.size)}, }, visibleColumns.downSpeed && { @@ -301,6 +332,7 @@ export default function DownloadClientsWidget({ title: t("items.downSpeed.columnTitle"), sortable: true, width: 90, + noWrap: true, render: (record) => record.downSpeed ? ( @@ -313,6 +345,7 @@ export default function DownloadClientsWidget({ title: t("items.upSpeed.columnTitle"), sortable: true, width: 90, + noWrap: true, render: (record) => record.upSpeed ? ( @@ -325,6 +358,7 @@ export default function DownloadClientsWidget({ title: t("items.time.columnTitle"), sortable: true, width: 100, + noWrap: true, render: (record) => { if (record.time === 0) return ∞; return {dayjs().add(record.time).fromNow(true)}; @@ -345,6 +379,7 @@ export default function DownloadClientsWidget({ title: t("items.added.columnTitle"), sortable: true, width: 90, + noWrap: true, render: (record) => {record.added ? dayjs(record.added).fromNow() : "—"}, }, visibleColumns.ratio && { @@ -361,6 +396,7 @@ export default function DownloadClientsWidget({ title: t("items.received.columnTitle"), sortable: true, width: 80, + noWrap: true, render: (record) => {formatBytes(record.received)}, }, visibleColumns.sent && { @@ -368,12 +404,14 @@ export default function DownloadClientsWidget({ title: t("items.sent.columnTitle"), sortable: true, width: 80, + noWrap: true, render: (record) => record.sent ? {formatBytes(record.sent)} : null, }, visibleColumns.category && { accessor: "category", title: t("items.category.columnTitle"), width: 80, + ellipsis: true, render: (record) => 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 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(() => { @@ -425,6 +462,18 @@ export default function DownloadClientsWidget({ return [...new Set(data.map(({ state }) => state))]; }, [data]); + 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 (
@@ -435,12 +484,27 @@ export default function DownloadClientsWidget({ return ( + {showStats && ( + + )} + ({ padding: `${size.cellPadding}px 8px` }), + }} onRowContextMenu={isEditMode ? undefined : handleContextMenu} + rowBackgroundColor={(record) => rowBgAlpha[record.state] as string | undefined} rowExpansion={{ allowMultiple: true, - content: ({ record }) => , + collapseProps: { + transitionDuration: 200, + animateOpacity: true, + transitionTimingFunction: "ease-out", + }, + content: ({ record, collapse }) => , + }} + onScroll={() => { + if (contextMenu) closeContextMenu(); }} /> @@ -472,6 +549,8 @@ export default function DownloadClientsWidget({ pauseQueue={mutatePauseQueue} resumeQueue={mutateResumeQueue} itemCount={sortedData.length} + showStats={showStats} + toggleStats={toggleStats} /> {contextMenu && ( @@ -485,50 +564,189 @@ export default function DownloadClientsWidget({ ); } -function ExpandedRow({ item }: { item: ExtendedDownloadClientItem }) { - const t = useScopedI18n("widget.downloads"); +function buildHoverTooltip( + record: ExtendedDownloadClientItem, + t: ReturnType>, +): 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 ( - - - - - {item.integration.name} - ({item.integration.kind}) + + {record.name} + + {lines.map(({ label, value }) => ( + + {label} + {value} + ))} + + ); +} - - - {item.sent !== undefined && } - {item.downSpeed !== undefined && } - {item.upSpeed !== undefined && } - {item.ratio !== undefined && } - - void }) { + const t = useScopedI18n("widget.downloads"); + + const progressPercent = Math.floor(item.progress * 100); + + return ( + + + {`${progressPercent}%`}} /> - {item.added !== undefined && } - {item.category && ( - - )} + + + + + {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 && } + {item.category && ( + + )} + + + ); } -function DetailPair({ label, value }: { label: string; value: string }) { +function DetailPair({ label, value, icon, color }: { label: string; value: string; icon?: React.ReactNode; color?: string }) { return ( - - {label}: - {value} + + {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"); + const overallProgress = queueStats.totalSize > 0 ? queueStats.completedSize / queueStats.totalSize : 0; + + return ( + + + + + + + {`${Math.floor(overallProgress * 100)}%`} + + + + + + + {`${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; @@ -537,7 +755,7 @@ function RowContextMenu({ state, onClose, t }: { const { item } = state; const isPaused = item.state === "paused"; const hasActions = !!item.actions; - const [showDeleteConfirm, { open: openDelete, close: closeDelete }] = useDisclosure(false); + const [showDeleteConfirm, { open: openDelete }] = useDisclosure(false); return ( @@ -633,6 +851,8 @@ interface WidgetFooterProps { pauseQueue: (args: { integrationIds: string[] }) => void; resumeQueue: (args: { integrationIds: string[] }) => void; itemCount: number; + showStats: boolean; + toggleStats: () => void; } function WidgetFooter({ @@ -649,6 +869,8 @@ function WidgetFooter({ pauseQueue, resumeQueue, itemCount, + showStats, + toggleStats, }: WidgetFooterProps) { const t = useScopedI18n("widget.downloads"); const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false); @@ -737,6 +959,16 @@ function WidgetFooter({ + + + {showStats ? : } + + + {clients.map(({ integration }) => (