diff --git a/.cursor/goal/scratchpad.md b/.cursor/goal/scratchpad.md index 5ad5b7d84..659f2adc4 100644 --- a/.cursor/goal/scratchpad.md +++ b/.cursor/goal/scratchpad.md @@ -1,5 +1,5 @@ --- -iteration: 1 +iteration: 2 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" @@ -19,9 +19,37 @@ engine: cursor ## Progress log +### Iteration 1 +- Added hover tooltips on name column showing size, state, speeds, ETA, ratio, category +- Added toggleable global stats bar with overall progress, total size, speeds, ratio, status counts +- Enhanced row expansion with RingProgress, SimpleGrid layout, collapse on click +- Added striped rows, highlightOnHover colors, rowBackgroundColor for failed/paused +- Added defaultColumnProps with noWrap and dynamic cellsStyle +- Added collapseProps for smooth expansion animation +- Added onScroll to auto-close context menu +- Added new translations for stats bar +- TypeCheck: PASS + +### Iteration 2 +- Migrated to `useDataTableColumns` hook with toggleable, draggable, resizable, pinnable columns +- Added `storeColumnsKey` for localStorage column preference persistence +- Added `pinFirstColumn`, `pinLastColumn`, `textSelectionDisabled`, `withColumnBorders` +- Added `scrollAreaProps`, `customRowAttributes`, enhanced scroll tracking (`onScroll`, `onScrollToTop`, `onScrollToBottom`) +- Added column settings menu in footer with reset visibility/order/width/pinning +- Added scroll position indicator badge when scrolled +- Explicit `rowExpansion.trigger: "click"` +- Updated docs and translations for column controls +- TypeCheck: PASS + ## Blockers +None ## Verification +- pnpm turbo typecheck --filter=@homarr/widgets: PASS (exit code 0) +- Context menu: preserved via onRowContextMenu +- Sorting: preserved via sortStatus/onSortStatusChange +- Filtering: preserved in WidgetFooter +- Column toggling/reordering/resizing/pinning: via useDataTableColumns + header UI ## Task diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index fff9decbe..621d57265 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -44,8 +44,11 @@ import contextMenuImg from './img/context-menu.png'; - **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes - **Sortable columns** — click any column header to sort ascending/descending +- **Column customization** — toggle, reorder, resize, and pin columns via the header menu; preferences persist in local storage - **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu - **Row expansion** — click any row to see full details inline without opening a separate modal +- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category +- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts - **Client filtering** — filter downloads by integration or status using the footer filter panel - **Global controls** — pause/resume all downloads across all clients from the footer bar - **Status indicators** — color-coded badges show download state at a glance @@ -80,6 +83,9 @@ import contextMenuImg from './img/context-menu.png'; - **Right-click** any download to open a context menu with pause/resume, delete, and copy actions - **Click** any row to expand it and see full download details inline +- **Hover** a download name for a quick stats tooltip +- **Column header menu** — show/hide, reorder, resize, or pin columns (reset via the columns button in the footer) +- **Stats toggle** — show or hide the global stats bar from the footer - **Pause / Resume** all downloads using the footer controls - **Filter** by integration or status using the filter button in the footer - **Sort** by clicking column headers (with persistent sort direction) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 00b39ab51..14276ddd3 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3189,6 +3189,14 @@ "overallProgress": "Overall queue progress", "totalSize": "Total queue size", "clients": "{count, plural, one {# client} other {# clients}}" + }, + "columns": { + "settings": "Column settings", + "resetVisibility": "Reset visibility", + "resetOrder": "Reset order", + "resetWidth": "Reset widths", + "resetPinning": "Reset pinning", + "scrolled": "Scrolled {y}px from top" } }, "mediaReleases": { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 09ff17b92..e518ecc93 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -26,6 +26,7 @@ import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp, + IconColumns, IconCopy, IconDatabase, IconDownload, @@ -41,7 +42,7 @@ import { import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; -import { DataTable } from "mantine-datatable"; +import { DataTable, useDataTableColumns } from "mantine-datatable"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; @@ -86,6 +87,8 @@ const stateColorMap: Record = { unknown: "gray", }; +const DOWNLOADS_TABLE_STORE_KEY = "homarr-downloads-datatable"; + const progressColor = (state: ExtendedDownloadClientItem["state"], progress: number): string => { if (state === "paused") return "yellow"; if (progress >= 1) return "green"; @@ -132,6 +135,8 @@ export default function DownloadClientsWidget({ const [statusFilter, setStatusFilter] = useState([]); const [contextMenu, setContextMenu] = useState(null); const [showStats, { toggle: toggleStats }] = useDisclosure(false); + const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 }); + const [scrollAtTop, setScrollAtTop] = useState(true); const utils = clientApi.useUtils(); const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; @@ -244,45 +249,60 @@ export default function DownloadClientsWidget({ const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1; const size = getSizeConfig(width); + const optionsColumnSet = useMemo(() => new Set(options.columns), [options.columns]); - 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 shouldIncludeColumn = useCallback( + (accessor: string): boolean => { + if (!optionsColumnSet.has(accessor)) return false; + if ((accessor === "upSpeed" || accessor === "ratio" || accessor === "sent") && !hasTorrents) return false; + if (accessor === "integration" && !hasMultipleClients) return false; + if (accessor === "type" && !hasMultipleTypes) return false; + return true; + }, + [optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes], + ); + + const isResponsiveVisible = useCallback( + (accessor: string): boolean => { + if ((accessor === "downSpeed" || accessor === "upSpeed") && !size.showSpeedColumns) return false; + if (accessor === "time" && !size.showTimeColumn) return false; + if (accessor === "state" && !size.showStateColumn) return false; + return true; + }, + [size], + ); + + const getColumnMeta = useCallback( + (accessor: string) => ({ + toggleable: true as const, + draggable: true as const, + resizable: accessor !== "integration" && accessor !== "index", + pinnable: true as const, + defaultToggle: isResponsiveVisible(accessor), + ...(accessor === "name" ? { pinned: "left" as const } : {}), + }), + [isResponsiveVisible], + ); const columns = useMemo((): DataTableColumn[] => { const cols: (DataTableColumn | false)[] = [ - visibleColumns.integration && { + shouldIncludeColumn("integration") && { accessor: "integration", title: "", width: 36, + ...getColumnMeta("integration"), render: (record) => ( ), }, - visibleColumns.name && { + shouldIncludeColumn("name") && { accessor: "name", title: t("items.name.columnTitle"), sortable: true, ellipsis: true, + ...getColumnMeta("name"), render: (record) => ( ), }, - visibleColumns.progress && { + shouldIncludeColumn("progress") && { accessor: "progress", title: t("items.progress.columnTitle"), sortable: true, width: width < 400 ? 80 : 120, + ...getColumnMeta("progress"), render: (record) => ( @@ -319,20 +340,22 @@ export default function DownloadClientsWidget({ ), }, - visibleColumns.size && { + shouldIncludeColumn("size") && { accessor: "size", title: t("items.size.columnTitle"), sortable: true, width: 80, noWrap: true, + ...getColumnMeta("size"), render: (record) => {formatBytes(record.size)}, }, - visibleColumns.downSpeed && { + shouldIncludeColumn("downSpeed") && { accessor: "downSpeed", title: t("items.downSpeed.columnTitle"), sortable: true, width: 90, noWrap: true, + ...getColumnMeta("downSpeed"), render: (record) => record.downSpeed ? ( @@ -340,12 +363,13 @@ export default function DownloadClientsWidget({ ) : null, }, - visibleColumns.upSpeed && { + shouldIncludeColumn("upSpeed") && { accessor: "upSpeed", title: t("items.upSpeed.columnTitle"), sortable: true, width: 90, noWrap: true, + ...getColumnMeta("upSpeed"), render: (record) => record.upSpeed ? ( @@ -353,88 +377,108 @@ export default function DownloadClientsWidget({ ) : null, }, - visibleColumns.time && { + shouldIncludeColumn("time") && { accessor: "time", title: t("items.time.columnTitle"), sortable: true, width: 100, noWrap: true, + ...getColumnMeta("time"), render: (record) => { if (record.time === 0) return ∞; return {dayjs().add(record.time).fromNow(true)}; }, }, - visibleColumns.state && { + shouldIncludeColumn("state") && { accessor: "state", title: t("items.state.columnTitle"), width: 90, + ...getColumnMeta("state"), render: (record) => ( {t(`states.${record.state}`)} ), }, - visibleColumns.added && { + shouldIncludeColumn("added") && { accessor: "added", title: t("items.added.columnTitle"), sortable: true, width: 90, noWrap: true, + ...getColumnMeta("added"), render: (record) => {record.added ? dayjs(record.added).fromNow() : "—"}, }, - visibleColumns.ratio && { + shouldIncludeColumn("ratio") && { accessor: "ratio", title: t("items.ratio.columnTitle"), sortable: true, width: 60, + ...getColumnMeta("ratio"), render: (record) => record.ratio !== undefined ? ( {record.ratio.toFixed(record.ratio >= 100 ? 0 : record.ratio >= 10 ? 1 : 2)} ) : null, }, - visibleColumns.received && { + shouldIncludeColumn("received") && { accessor: "received", title: t("items.received.columnTitle"), sortable: true, width: 80, noWrap: true, + ...getColumnMeta("received"), render: (record) => {formatBytes(record.received)}, }, - visibleColumns.sent && { + shouldIncludeColumn("sent") && { accessor: "sent", title: t("items.sent.columnTitle"), sortable: true, width: 80, noWrap: true, + ...getColumnMeta("sent"), render: (record) => record.sent ? {formatBytes(record.sent)} : null, }, - visibleColumns.category && { + shouldIncludeColumn("category") && { accessor: "category", title: t("items.category.columnTitle"), width: 80, ellipsis: true, + ...getColumnMeta("category"), render: (record) => record.category ? ( {Array.isArray(record.category) ? record.category.join(", ") : record.category} ) : null, }, - visibleColumns.index && { + shouldIncludeColumn("index") && { accessor: "index", title: t("items.index.columnTitle"), sortable: true, width: 40, + ...getColumnMeta("index"), render: (record) => {record.index}, }, - visibleColumns.type && { + shouldIncludeColumn("type") && { accessor: "type", title: t("items.type.columnTitle"), sortable: true, width: 70, + ...getColumnMeta("type"), render: (record) => {record.type}, }, ]; return cols.filter(Boolean) as DataTableColumn[]; - }, [visibleColumns, t, size, width]); + }, [shouldIncludeColumn, getColumnMeta, t, size, width]); + + const { + effectiveColumns, + resetColumnsToggle, + resetColumnsOrder, + resetColumnsWidth, + resetColumnsPinning, + } = useDataTableColumns({ + key: DOWNLOADS_TABLE_STORE_KEY, + columns, + }); const handleContextMenu = useCallback( ({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => { @@ -499,6 +543,7 @@ export default function DownloadClientsWidget({ ({ padding: `${size.cellPadding}px 8px` }), }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + customRowAttributes={(_, index) => ({ "data-row-index": index })} onRowContextMenu={isEditMode ? undefined : handleContextMenu} rowBackgroundColor={(record) => rowBgAlpha[record.state] as string | undefined} rowExpansion={{ + trigger: "click", allowMultiple: true, collapseProps: { transitionDuration: 200, @@ -529,9 +581,13 @@ export default function DownloadClientsWidget({ }, content: ({ record, collapse }) => , }} - onScroll={() => { + onScroll={(position) => { + setScrollPosition(position); + setScrollAtTop(position.y <= 0); if (contextMenu) closeContextMenu(); }} + onScrollToTop={() => setScrollAtTop(true)} + onScrollToBottom={() => setScrollAtTop(false)} /> @@ -551,6 +607,12 @@ export default function DownloadClientsWidget({ itemCount={sortedData.length} showStats={showStats} toggleStats={toggleStats} + scrollAtTop={scrollAtTop} + scrollPosition={scrollPosition} + resetColumnsToggle={resetColumnsToggle} + resetColumnsOrder={resetColumnsOrder} + resetColumnsWidth={resetColumnsWidth} + resetColumnsPinning={resetColumnsPinning} /> {contextMenu && ( @@ -853,6 +915,12 @@ interface WidgetFooterProps { itemCount: number; showStats: boolean; toggleStats: () => void; + scrollAtTop: boolean; + scrollPosition: { x: number; y: number }; + resetColumnsToggle: () => void; + resetColumnsOrder: () => void; + resetColumnsWidth: () => void; + resetColumnsPinning: () => void; } function WidgetFooter({ @@ -871,6 +939,12 @@ function WidgetFooter({ itemCount, showStats, toggleStats, + scrollAtTop, + scrollPosition, + resetColumnsToggle, + resetColumnsOrder, + resetColumnsWidth, + resetColumnsPinning, }: WidgetFooterProps) { const t = useScopedI18n("widget.downloads"); const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false); @@ -969,6 +1043,31 @@ function WidgetFooter({ + + + + + + + + + + {t("columns.settings")} + {t("columns.resetVisibility")} + {t("columns.resetOrder")} + {t("columns.resetWidth")} + {t("columns.resetPinning")} + + + + {!scrollAtTop && ( + + + ↕ + + + )} + {clients.map(({ integration }) => (