diff --git a/.cursor/rules/mantine-datatable-migration.mdc b/.cursor/rules/mantine-datatable-migration.mdc new file mode 100644 index 000000000..08d2b8202 --- /dev/null +++ b/.cursor/rules/mantine-datatable-migration.mdc @@ -0,0 +1,622 @@ +# Migrating Tables from mantine-react-table to mantine-datatable + +This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. + +## Migration Candidates + +Current `mantine-react-table` consumers that could be migrated: + +**Widgets (in `packages/widgets/`):** +- `docker/component.tsx` — Docker container table +- `media-server/component.tsx` — Media server sessions + +**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** +- `tools/docker/docker-table.tsx` +- `tools/tasks/_components/tasks-table.tsx` +- `tools/api/components/api-keys.tsx` +- `users/_components/user-list.tsx` +- `users/invites/_components/invite-list.tsx` +- `tools/kubernetes/` — 8 Kubernetes resource tables + +**Modals:** +- `packages/modals-collection/src/search-engines/request-media-modal.tsx` + +**Shared hook to remove once all migrated:** +- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` + +--- + +## 1. Core Migration: mantine-react-table → mantine-datatable + +### Import changes + +```typescript +// BEFORE +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { MRT_ColumnDef } from "mantine-react-table"; + +// AFTER +import { DataTable, useDataTableColumns } from "mantine-datatable"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +``` + +### Column definition shape + +```typescript +// BEFORE (MRT) +const columns: MRT_ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + size: 200, + enableSorting: true, + Cell: ({ row }) => {row.original.name}, + }, +]; + +// AFTER (mantine-datatable) +const columns: DataTableColumn[] = [ + { + accessor: "name", // not accessorKey + title: "Name", // not header + width: 200, // not size + sortable: true, // not enableSorting + render: (record) => {record.name}, // not Cell + }, +]; +``` + +Key differences: +- `accessorKey` → `accessor` +- `header` → `title` +- `size` → `width` +- `Cell: ({ row }) =>` → `render: (record) =>` +- `enableSorting` → `sortable` +- `enableColumnActions` → does not exist (no column action menus) +- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps` + +### Table component + +```typescript +// BEFORE +const table = useTranslatedMantineReactTable({ columns, data, ... }); +return ; + +// AFTER +return ( + +); +``` + +### Sorting is manual + +mantine-datatable does NOT sort for you. You must sort the data yourself: + +```typescript +const [sortStatus, setSortStatus] = useState>({ + columnAccessor: "name", + direction: "asc", +}); + +const sortedData = useMemo(() => { + const mult = sortStatus.direction === "desc" ? -1 : 1; + return [...data].toSorted((a, b) => { + const aVal = a[sortStatus.columnAccessor]; + const bVal = b[sortStatus.columnAccessor]; + if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult; + return ((aVal as number) - (bVal as number)) * mult; + }); +}, [data, sortStatus]); + +// Pass sortedData to records, not data + +``` + +--- + +## 2. Making Tables Transparent (Widget Context) + +Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. + +Create a CSS file alongside the component: + +```css +/* styles.css */ +.my-table, +.my-table .mantine-datatable-table, +.my-table .mantine-datatable-table thead, +.my-table .mantine-datatable-table tbody, +.my-table .mantine-datatable-table tfoot, +.my-table th, +.my-table td { + background-color: transparent !important; +} + +.my-table .mantine-datatable-table tr { + background-color: transparent; +} + +/* Hover: translucent instead of opaque */ +.my-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; +} + +/* Headers: frosted glass effect */ +.my-table th { + white-space: nowrap; + background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + backdrop-filter: blur(8px); +} + +/* Cell overflow */ +.my-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Hide sort icon until hover */ +.my-table .mantine-datatable-header-cell-sortable-icon { + opacity: 0; + transition: opacity 150ms; +} +.my-table th:hover .mantine-datatable-header-cell-sortable-icon { + opacity: 1; +} + +/* Row expansion area */ +.my-table .mantine-datatable-row-expansion-cell { + background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; +} +``` + +Import it: `import "./styles.css";` and apply via `className="my-table"`. + +--- + +## 3. Responsive Column Visibility + +Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. + +```typescript +interface SizeConfig { + fontSize: "xs" | "sm"; + iconSize: number; + cellPadding: number; + showSpeedColumns: boolean; + showTimeColumn: boolean; + showStateColumn: boolean; +} + +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 }; + +function getSizeConfig(width: number): SizeConfig { + for (const { maxWidth, config } of SIZE_BREAKPOINTS) { + if (width < maxWidth) return config; + } + return DEFAULT_SIZE_CONFIG; +} + +// Usage: memoize it +const size = useMemo(() => getSizeConfig(width), [width]); +``` + +Then use a visibility check lookup table for columns: + +```typescript +const columnVisibilityChecks: Partial boolean>> = { + upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, + downSpeed: (ctx) => ctx.size.showSpeedColumns, + time: (ctx) => ctx.size.showTimeColumn, + state: (ctx) => ctx.size.showStateColumn, +}; + +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; +} +``` + +--- + +## 4. Column Persistence (Drag/Resize → Server) + +`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. + +### Hidden widget options + +In `index.ts`, add hidden text options: + +```typescript +columnOrder: factory.text({ defaultValue: "" }), +columnWidths: factory.text({ defaultValue: "" }), +``` + +With visibility override: + +```typescript +{ + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, +} +``` + +### Translation keys required + +Even hidden options need translation keys or the translation test fails: + +```json +"widget.mywidget.option.columnOrder.label": "Column order", +"widget.mywidget.option.columnWidths.label": "Column widths" +``` + +### Old-import mappings + +Add to `packages/old-import/src/widgets/options.ts`: + +```typescript +columnOrder: () => undefined, +columnWidths: () => undefined, +``` + +### Dynamic storeKey + +Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: + +```typescript +const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; +``` + +### Hydration from server → localStorage + +On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: + +```typescript +const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = + useDataTableColumns({ key: storeKey, columns }); + +const lastStoreKey = useRef(storeKey); +const hydrated = useRef(false); + +useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + if (savedOrder.length > 0) setColumnsOrder(savedOrder); + if (Object.keys(savedWidths).length > 0) { + setMultipleColumnWidths( + Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), + ); + } + requestAnimationFrame(() => { hydrated.current = true; }); +}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); +``` + +### Persist localStorage → server + +Watch for changes and write back: + +```typescript +const prevOrder = useRef(columnsOrder); +const prevWidths = useRef(columnsWidth); + +useEffect(() => { + if (!hydrated.current) return; + const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + prevOrder.current = columnsOrder; + prevWidths.current = columnsWidth; + + if (!orderChanged && !widthsChanged) return; + + if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (widthsChanged) { + const widthMap: Record = {}; + for (const entry of columnsWidth) { + const key = Object.keys(entry)[0]; + if (!key) continue; + const w = entry[key as keyof typeof entry]; + if (typeof w === "number") widthMap[key] = w; + else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); + } + persistOption({ columnWidths: JSON.stringify(widthMap) }); + } +}, [columnsOrder, columnsWidth, persistOption]); +``` + +The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). + +--- + +## 5. Context Menu (Right-Click) + +Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: + +```typescript +interface ContextMenuState { + x: number; + y: number; + item: MyRow; +} + +const [contextMenu, setContextMenu] = useState(null); + +const handleContextMenu = useCallback( + ({ record, event }: { record: MyRow; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, item: record }); + }, + [], +); + +// On DataTable: + + +// Render: +{contextMenu && } +``` + +The menu component uses `Portal` for correct z-index: + +```tsx +function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { + return ( + + + + + + + { /* action */ onClose(); }}>Action + + + + ); +} +``` + +**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. + +--- + +## 6. Row Expansion + +```typescript + , + }} +/> +``` + +**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. + +--- + +## 7. Common Pitfalls & Fixes + +### Status indicator dots + +Don't use `` without children — it renders a pill with empty space. Use a plain `Box`: + +```tsx + +``` + +### Tooltip overflow on long text columns + +When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix: + +```tsx + + + ... + + +``` + +Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally. + +### Dark mode tooltips + +Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets. + +### Division by zero + +Guard all ratio calculations: + +```typescript +if (item.size > 0) ratio = item.sent / item.size; +if (totalDown > 0) ratio = totalUp / totalDown; +``` + +### Composite idAccessor + +When rows can come from multiple integrations, IDs may collide. Use a composite key: + +```typescript +idAccessor={(record) => `${record.integration.id}:${record.id}`} +``` + +### JSON option parsing + +Always wrap in try/catch: + +```typescript +function parseJsonOption(value: string | undefined, fallback: T): T { + if (!value) return fallback; + try { return JSON.parse(value) as T; } + catch { return fallback; } +} +``` + +### Clipboard API + +`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch: + +```typescript +void navigator.clipboard.writeText(text).catch(() => {}); +``` + +### Sync sortStatus with widget options + +If the widget has configurable default sort, sync it: + +```typescript +useEffect(() => { + setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); +}, [options.defaultSort, defaultSortDirection]); +``` + +--- + +## 8. Accessibility + +- All `ActionIcon` components need `aria-label` +- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` +- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize + +--- + +## 9. Error Handling + +- Every mutation needs an `onError` callback with `showErrorNotification` +- The `saveItemOptions` mutation (column persistence) also needs `onError` +- Check `isError` on the main query and show a fallback UI +- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`: + +```typescript +const mutationOptions = { + onSettled: () => void utils.myQuery.invalidate(), + onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), +}; +``` + +--- + +## 10. i18n + +- No hardcoded strings. Every user-visible string goes through `useScopedI18n` +- Column titles: `t("items.{accessor}.columnTitle")` +- Detail labels: `t("items.{accessor}.detailsTitle")` +- States: `t("states.{state}")` +- Actions: `t("actions.{action}")` +- Errors: `t("errors.{errorKey}")` +- Hidden options still need `.label` translation keys + +--- + +## 11. Edit Mode + +Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: + +```typescript +let tablePointerEvents: React.CSSProperties["pointerEvents"]; +if (isEditMode) tablePointerEvents = "none"; + +let rowContextMenuHandler = handleContextMenu; +if (isEditMode) rowContextMenuHandler = undefined; + + +``` + +--- + +## 12. DataTable Props Reference (Commonly Used) + +```typescript + record.uniqueKey} + height="100%" + className="my-table" + defaultColumnProps={{ + noWrap: true, + draggable: true, + resizable: true, + cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + rowBackgroundColor={(record) => conditionalBgMap[record.state]} + onRowContextMenu={handleContextMenu} + rowExpansion={{ ... }} + onScroll={() => { if (contextMenu) closeContextMenu(); }} +/> +``` + +--- + +## 13. Layout Stability + +- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic +- Give non-name columns explicit `width` values so only the name column flexes +- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally +- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length +- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names + +--- + +## 14. Checklist for Each Migration + +1. Replace imports (`mantine-react-table` → `mantine-datatable`) +2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.) +3. Implement manual sorting with `useState` + `useMemo` +4. Add CSS file for transparent backgrounds (if widget context) +5. Add responsive column visibility (if widget context) +6. Add hidden options + hydration logic (if column persistence needed) +7. Add old-import mappings for new options +8. Add translation keys (including hidden option labels) +9. Add context menu if row actions exist +10. Add row expansion if detail view exists +11. Add error handling on all mutations +12. Add accessibility attributes +13. Test at multiple widget widths +14. Update docs in `apps/docs/docs/widgets/` if applicable +15. Run `pnpm turbo typecheck` — catches old-import mismatches +16. Run translation spec — catches missing keys diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 98c7d1693..900c3d97e 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,6 +1,6 @@ { $schema: "https://docs.renovatebot.com/renovate-schema.json", - extends: ["config:recommended"], + extends: ["config:recommended", ":dependencyDashboardApproval"], packageRules: [ { matchPackageNames: ["^@homarr/"], @@ -47,7 +47,7 @@ rangeStrategy: "bump", automerge: false, baseBranchPatterns: ["dev"], - dependencyDashboard: false, + dependencyDashboard: true, // prevent special characters in branch names (otherwise git errors occur on windows) branchNameStrict: true, } diff --git a/apps/docs/docs/getting-started/installation/helm.md b/apps/docs/docs/getting-started/installation/helm.md index d768ec69a..0d897ffa9 100644 --- a/apps/docs/docs/getting-started/installation/helm.md +++ b/apps/docs/docs/getting-started/installation/helm.md @@ -2,9 +2,9 @@ homarr logo -![Version: 8.23.0](https://img.shields.io/badge/Version-8.23.0-informational?style=flat) +![Version: 8.24.0](https://img.shields.io/badge/Version-8.24.0-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) -![AppVersion: v1.71.0](https://img.shields.io/badge/AppVersion-v1.71.0-informational?style=flat) +![AppVersion: v1.72.0](https://img.shields.io/badge/AppVersion-v1.72.0-informational?style=flat) A Helm chart to deploy homarr for Kubernetes @@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | -| image.tag | string | `"v1.71.0"` | Overrides the image tag whose default is the chart appVersion | +| image.tag | string | `"v1.72.0"` | Overrides the image tag whose default is the chart appVersion | | imagePullSecrets | list | `[]` | Secrets for Docker registry | | ingress.annotations | object | `{}` | Ingress annotations | | ingress.enabled | bool | `false` | Enable ingress | diff --git a/apps/docs/docs/widgets/downloads/img/context-menu.png b/apps/docs/docs/widgets/downloads/img/context-menu.png new file mode 100644 index 000000000..350051d00 Binary files /dev/null and b/apps/docs/docs/widgets/downloads/img/context-menu.png differ diff --git a/apps/docs/docs/widgets/downloads/img/screenshot.png b/apps/docs/docs/widgets/downloads/img/screenshot.png index c6cdde381..8ef3a1701 100644 Binary files a/apps/docs/docs/widgets/downloads/img/screenshot.png and b/apps/docs/docs/widgets/downloads/img/screenshot.png differ diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 9242a84eb..000fd066b 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -4,6 +4,7 @@ description: 'Allows you to view and manage your Downloads from both Torrent and hide_title: true --- +import { aria2Integration } from '@site/docs/integrations/aria2'; import { delugeIntegration } from '@site/docs/integrations/deluge'; import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; @@ -14,9 +15,6 @@ import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; import { downloadsWidget } from '.'; @@ -25,16 +23,37 @@ import { downloadsWidget } from '.'; categories={['Torrent client', 'Usenet client', 'Downloads']} /> -This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. +This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. ### Screenshots import downloadsCard from './img/screenshot.png'; +import contextMenuImg from './img/context-menu.png';
- downloads + Downloads widget showing torrent and usenet items
+#### Right-click context menu + +
+ Downloads widget context menu with pause, delete, and copy actions +
+ +### Features + +- **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** — choose which columns to display via the widget options panel +- **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 +- **Speed overview** — total download and upload speeds displayed in the footer + ### Supported Integrations ### Interactions -- Pause / Resume one download by clicking on the according icon. -- Delete one download by clicking on the trash icon. (Can also delete the files) -- Pause / Resume all downloads by using the bottom button. +- **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 +- **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) ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.ts b/apps/docs/docs/widgets/downloads/index.ts index 4810aec89..4245af310 100644 --- a/apps/docs/docs/widgets/downloads/index.ts +++ b/apps/docs/docs/widgets/downloads/index.ts @@ -2,26 +2,23 @@ import { WidgetDefinition } from "@site/src/types"; import { IconDownload } from "@tabler/icons-react"; const columnsList = [ - "id", - "actions", - "added", - "category", - "downSpeed", - "index", - "integration", "name", "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", "ratio", "received", "sent", - "size", - "state", - "time", + "category", + "integration", + "index", "type", - "upSpeed", ]; -const sortingExclusion = ["actions", "id", "state"]; -const columnsSort = columnsList.filter((column) => !sortingExclusion.some((exclusion) => exclusion === column)); +const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c)); export const downloadsWidget: WidgetDefinition = { icon: IconDownload, @@ -32,25 +29,20 @@ export const downloadsWidget: WidgetDefinition = { items: [ { name: "Columns to show", - description: "Select the columns you want to display in the widget.", + description: + "Select the columns you want to display in the widget. Columns automatically hide/show based on widget width.", values: `List of columns: ${columnsList.join(", ")}`, - defaultValue: ["integration", "name", "progress", "time", "actions"].join(", "), + defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "), }, { - name: "Enable items sorting", - description: "Enables the sorting in the table.", - values: { type: "boolean" }, - defaultValue: "no", - }, - { - name: "Column used for sorting by default", - description: "By default it will sort by this column.", - defaultValue: "type", - values: `List of columns: ${columnsSort.join(", ")}`, + name: "Default sort column", + description: "The column used for initial sorting when the widget loads.", + defaultValue: "progress", + values: `List of columns: ${sortColumns.join(", ")}`, }, { name: "Invert sorting", - description: "This will invert the sorting order of the table.", + description: "This will invert the default sorting order.", values: { type: "boolean" }, defaultValue: "no", }, @@ -76,13 +68,13 @@ export const downloadsWidget: WidgetDefinition = { name: "Hide completed torrent under this threshold (in kiB/s)", description: "This will hide completed torrent entries that have a download speed below the specified threshold.", - values: "Any number above 1, 0 to disable", + values: "Any number above 0, 0 to disable", defaultValue: "0", }, { - name: "Categories/names to filter", - description: "You can filter the items by categories or names. Use a comma to separate multiple values.", - values: "Comma-separated list of categories or names", + name: "Categories/labels to filter", + description: "You can filter the items by categories or labels. Use a comma to separate multiple values.", + values: "Comma-separated list of categories or labels", defaultValue: "-", }, { @@ -94,13 +86,13 @@ export const downloadsWidget: WidgetDefinition = { }, { name: "Use filter to calculate Ratio", - description: "This will use the filter to calculate the ratio of items shown in the widget.", + description: "This will use the category filter when calculating the global torrent ratio.", values: { type: "boolean" }, defaultValue: "yes", }, { name: "Limit items per integration", - description: "This will limit the number of items shown per integration, not globally", + description: "This will limit the number of items shown per integration, not globally.", values: "Any number above 1", defaultValue: "50", }, diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 427784bf8..b0d169a54 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -22,6 +22,7 @@ import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { BoardItemMenu } from "./item-menu"; import { WidgetContextMenu } from "./widget-context-menu"; +import { removePersistedWidgetQueries } from "./widget-query-recovery"; interface BoardItemContentProps { item: SectionItem; @@ -165,7 +166,7 @@ const InnerContent = ({ {({ reset }) => ( { - void queryClient.invalidateQueries({ queryKey: [["widget"]] }); + removePersistedWidgetQueries(queryClient); reset(); }} fallbackRender={({ resetErrorBoundary, error }) => ( diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts new file mode 100644 index 000000000..b666df4e9 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts @@ -0,0 +1,54 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, test } from "vitest"; + +import { removePersistedWidgetQueries } from "./widget-query-recovery"; + +describe("removePersistedWidgetQueries", () => { + test("removes stale widget queries and allows fresh data to be fetched", async () => { + const queryClient = new QueryClient(); + const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + const unrelatedQueryKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(crashedWidgetQueryKey, "stale"); + queryClient.setQueryData(unrelatedQueryKey, "unrelated"); + + removePersistedWidgetQueries(queryClient); + + expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); + await expect( + queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }), + ).resolves.toBe("fresh"); + }); + + test("removes persisted queries outside the widget router", () => { + const queryClient = new QueryClient(); + const dockerQueryKey = [["docker", "getContainers"], { input: {} }]; + const appQueryKey = [["app", "byId"], { input: {} }]; + const unrelatedQueryKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(dockerQueryKey, "stale"); + queryClient.setQueryData(appQueryKey, "stale"); + queryClient.setQueryData(unrelatedQueryKey, "unrelated"); + + removePersistedWidgetQueries(queryClient); + + expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(appQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); + }); + + test("removes persisted widget queries when the widget kind and query path differ", () => { + const queryClient = new QueryClient(); + const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }]; + const unrelatedQueryKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(crashedWidgetQueryKey, "stale"); + queryClient.setQueryData(unrelatedQueryKey, "unrelated"); + + removePersistedWidgetQueries(queryClient); + + expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined(); + expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); + }); +}); diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts new file mode 100644 index 000000000..800614bf4 --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts @@ -0,0 +1,7 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache"; + +export const removePersistedWidgetQueries = (queryClient: QueryClient) => { + queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) }); +}; diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index cf887fedc..1f2a0c235 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -30,8 +30,15 @@ const settleIntegrationQueriesInternal = async , ): Promise> => { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); - const results = settled.flatMap((result, index) => { - if (result.status === "fulfilled") return [result.value]; + const results: TResult[] = []; + const errors: unknown[] = []; + + settled.forEach((result, index) => { + if (result.status === "fulfilled") { + results.push(result.value); + return; + } + const integration = integrations[index]; logger.warn( new ErrorWithMetadata( @@ -40,8 +47,13 @@ const settleIntegrationQueriesInternal = async result.status === "rejected"); @@ -54,6 +66,10 @@ const settleIntegrationQueriesInternal = async 0) { + throw errors[0]; + } + return { results, failedIntegrationCount: settled.filter((result) => result.status === "rejected").length, diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts new file mode 100644 index 000000000..09f80bc31 --- /dev/null +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from "vitest"; + +import { settleIntegrationQueries } from "../settle-integrations"; + +const mocks = vi.hoisted(() => ({ + logger: { warn: vi.fn() }, +})); + +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => mocks.logger, +})); + +vi.mock("@homarr/core/infrastructure/logs/error", () => ({ + ErrorWithMetadata: Error, +})); + +const integrations = [ + { id: "first", name: "First", kind: "plex" }, + { id: "second", name: "Second", kind: "jellyfin" }, +]; + +describe("settleIntegrationQueries", () => { + test("returns an empty result when no integrations are configured", async () => { + await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]); + }); + + test("keeps successful integrations when another integration fails", async () => { + const error = new Error("Plex unavailable"); + + await expect( + settleIntegrationQueries(integrations, (integration) => + integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name), + ), + ).resolves.toEqual(["Second"]); + }); + + test("rejects when every integration fails", async () => { + const error = new Error("Integration unavailable"); + + await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error); + }); + + test("uses configured fallbacks when every integration fails", async () => { + await expect( + settleIntegrationQueries(integrations, () => Promise.reject(new Error("Integration unavailable")), { + fallback: (integration) => integration.name, + }), + ).resolves.toEqual(["First", "Second"]); + }); +}); diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts index edc071889..c08dd5848 100644 --- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie public async getClientJobsAndStatusAsync(input: { limit: number }): Promise { const type = "torrent"; const client = await this.getClientAsync(); - const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false }); + const torrents = await client.listTorrents({ limit: input.limit }); const rates = torrents.reduce( ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), { down: 0, up: 0 }, diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index 003696439..0cf84b493 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -77,15 +77,16 @@ const optionMapping: OptionMapping = { categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined), filterIsWhitelist: (oldOptions) => "labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined, - enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined), showCompletedTorrent: (oldOptions) => "displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined, - columns: () => ["integration", "name", "progress", "time", "actions"], - defaultSort: () => "type", + columns: () => ["integration", "name", "progress", "time", "state"], + defaultSort: () => "progress", descendingDefaultSort: () => false, showCompletedUsenet: () => true, showCompletedHttp: () => true, limitPerIntegration: () => undefined, + columnOrder: () => undefined, + columnWidths: () => undefined, }, weather: { forecastDayCount: (oldOptions) => oldOptions.forecastDays, diff --git a/packages/request-handler/src/media-release.ts b/packages/request-handler/src/media-release.ts index bc8320da3..994214b8e 100644 --- a/packages/request-handler/src/media-release.ts +++ b/packages/request-handler/src/media-release.ts @@ -13,5 +13,6 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler< const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMediaReleasesAsync(); }, + cacheTtlMs: 5 * 60 * 1000, fallbackToStaleOnError: true, }); diff --git a/packages/request-handler/src/test/media-release.spec.ts b/packages/request-handler/src/test/media-release.spec.ts new file mode 100644 index 000000000..825107afd --- /dev/null +++ b/packages/request-handler/src/test/media-release.spec.ts @@ -0,0 +1,45 @@ +import type { MediaRelease } from "@homarr/integrations/types"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { mediaReleaseRequestHandler } from "../media-release"; + +const mocks = vi.hoisted(() => ({ + createIntegrationAsync: vi.fn(), +})); + +vi.mock("@homarr/integrations", () => ({ + createIntegrationAsync: mocks.createIntegrationAsync, +})); + +const integration = { + id: "plex-integration", + kind: "plex", +} as Parameters[0]; + +describe("mediaReleaseRequestHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T00:00:00Z")); + mediaReleaseRequestHandler.invalidateCache(); + mocks.createIntegrationAsync.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("keeps the last successful releases when a refresh fails", async () => { + const releases = [{ id: "release-1" }] as MediaRelease[]; + const getMediaReleasesAsync = vi.fn().mockResolvedValueOnce(releases).mockRejectedValueOnce(new Error("Plex down")); + mocks.createIntegrationAsync.mockResolvedValue({ getMediaReleasesAsync }); + + const handler = mediaReleaseRequestHandler.handler(integration, {}); + const initial = await handler.getDataAsync(); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + const fallback = await handler.getDataAsync(); + + expect(fallback).toEqual(initial); + expect(getMediaReleasesAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ddee16b70..5faf0e5a5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3085,11 +3085,20 @@ "limitPerIntegration": { "label": "Limit items per integration", "description": "This will limit the number of items shown per integration, not globally" + }, + "columnOrder": { + "label": "Column order" + }, + "columnWidths": { + "label": "Column widths" } }, "errors": { "noColumns": "Select Columns in Items", - "noCommunications": "Can't load data from integration" + "noCommunications": "Can't load data from integration", + "noItems": "No downloads found", + "actionFailed": "Action failed", + "actionFailedMessage": "The action could not be completed. Please try again." }, "items": { "actions": { @@ -3112,7 +3121,8 @@ "detailsTitle": "Current index within client" }, "id": { - "columnTitle": "Id" + "columnTitle": "Id", + "detailsTitle": "Item ID" }, "integration": { "columnTitle": "Integration" @@ -3146,7 +3156,8 @@ }, "time": { "columnTitle": "Finish time", - "detailsTitle": "Time since/to completion" + "detailsTitle": "Time since/to completion", + "eta": "ETA" }, "type": { "columnTitle": "Type", @@ -3190,7 +3201,23 @@ } } }, - "globalRatio": "Global Ratio" + "globalRatio": "Global Ratio", + "contextMenu": { + "copyName": "Copy name", + "copyId": "Copy ID", + "clearFilters": "Clear filters" + }, + "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}}" + }, + "disconnected": "(disconnected)" }, "mediaReleases": { "name": "Media releases", diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 310e83c45..5ac7855b8 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -115,14 +115,22 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => { return Math.max(1, Math.floor(available / size.rowHeight)); }; -const MIN_CELL_WIDTH = 180; +const MIN_CELL_WIDTH = 140; const MIN_CELL_HEIGHT = 80; -const getColCount = (width: number, height: number, itemCount: number): number => { +const getColCount = (width: number, _height: number, itemCount: number): number => { if (itemCount <= 1) return 1; const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); - const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT); - return Math.min(maxCols, Math.max(1, Math.ceil(Math.sqrt(itemCount * aspectRatio)))); + + let best = 1; + for (let c = 1; c <= maxCols; c++) { + const emptyCells = (c - (itemCount % c)) % c; + const bestEmpty = (best - (itemCount % best)) % best; + if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) { + best = c; + } + } + return best; }; interface MetricRowProps { diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index b00cb44f9..b8005d1d2 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -3,126 +3,295 @@ import "../widgets-common.css"; import "./styles.css"; -import { useCallback, useMemo, useState } from "react"; -import type { MantineStyleProp } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActionIcon, Avatar, - AvatarGroup, - Button, + Badge, + Box, Center, - Chip, Divider, Group, - Modal, - Paper, - Popover, + Menu, + Portal, Progress, - Space, + RingProgress, + SimpleGrid, Stack, Text, - Title, Tooltip, + UnstyledButton, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { - IconAlertTriangle, - IconCirclesRelation, + IconChevronDown, + IconChevronUp, + IconCopy, + IconDatabase, + IconDownload, IconFilter, - IconInfinity, + IconFilterOff, IconInfoCircle, IconPlayerPause, IconPlayerPlay, IconTrash, - IconX, + IconTrashX, + IconUpload, } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; -import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +import { DataTable, useDataTableColumns } from "mantine-datatable"; -import type { RouterOutputs } from "@homarr/api"; 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 { showErrorNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetMobileLoading, WidgetMobileSummary } from "../common/mobile-summary"; -import { hasWidgetDataWarning, throwOnInitialQueryError, WidgetDataState } from "../common/query-state"; +import { hasWidgetDataWarning, throwOnInitialQueryError } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; dayjs.extend(relativeTime); -interface QuickFilter { - integrationKinds: string[]; - statuses: ExtendedDownloadClientItem["state"][]; +type DownloadsT = ReturnType>; +type DownloadState = ExtendedDownloadClientItem["state"]; + +interface SizeConfig { + fontSize: "xs" | "sm"; + iconSize: number; + cellPadding: number; + showSpeedColumns: boolean; + showTimeColumn: boolean; + showStateColumn: boolean; } -//Ratio table for relative width between columns -const columnsRatios: Record = { - actions: 2, - added: 4, - category: 1, - downSpeed: 3, - id: 1, - index: 1, - integration: 1, - name: 16, - progress: 6, - ratio: 2, - received: 3, - sent: 3, - size: 3, - state: 3, - time: 4, - type: 2, - upSpeed: 3, +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, }; -type DownloadsWidgetOptions = WidgetComponentProps<"downloads">["options"]; -type DownloadItem = RouterOutputs["widget"]["downloads"]["getJobsAndStatuses"][number]["data"]["items"][number]; -type DownloadVisibilityOptions = Pick< - DownloadsWidgetOptions, - | "activeTorrentThreshold" - | "categoryFilter" - | "filterIsWhitelist" - | "showCompletedHttp" - | "showCompletedTorrent" - | "showCompletedUsenet" ->; - -const matchesConfiguredFilters = (item: DownloadItem, options: DownloadVisibilityOptions) => { - const matchesCategoryFilter = - options.filterIsWhitelist === - options.categoryFilter.some((filter) => - (Array.isArray(item.category) ? item.category : [item.category]).includes(filter), - ); - const isVisibleCompletedItem = - (item.type === "torrent" && - ((item.progress === 1 && - options.showCompletedTorrent && - (item.upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) || - item.progress !== 1)) || - (item.type === "usenet" && ((item.progress === 1 && options.showCompletedUsenet) || item.progress !== 1)) || - (item.type === "miscellaneous" && ((item.progress === 1 && options.showCompletedHttp) || item.progress !== 1)); - - return matchesCategoryFilter && isVisibleCompletedItem; +const stateColorMap: Record = { + downloading: "blue", + leeching: "blue", + seeding: "green", + completed: "green", + processing: "cyan", + queued: "gray", + paused: "yellow", + stalled: "orange", + failed: "red", + unknown: "gray", }; -export default function DownloadClientsWidget(props: WidgetComponentProps<"downloads">) { +const rowBgAlpha: Partial> = { + failed: "rgba(255, 0, 0, 0.04)", + paused: "rgba(255, 200, 0, 0.03)", +}; + +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; + +function getSizeConfig(width: number): SizeConfig { + 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"; +} + +function parseJsonOption(value: string | undefined, fallback: T): T { + if (!value) return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +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 DownloadsWidget(props: WidgetComponentProps<"downloads">) { if (props.displayMode === "mobileSummary") { - return ; + return ; } - return ; + return ; } -const DownloadClientsMobileSummary = ({ +function DownloadsMobileSummary({ integrationIds, options, -}: Pick, "integrationIds" | "options">) => { +}: Pick, "integrationIds" | "options">) { const t = useScopedI18n("widget.downloads"); const { data, error, isPending } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ integrationIds, @@ -136,7 +305,10 @@ const DownloadClientsMobileSummary = ({ const selectedClients = currentItems.filter(({ integration }) => integrationIds.includes(integration.id)); const visibleItemCount = selectedClients.reduce( (total, { data: clientData }) => - total + clientData.items.filter((item) => matchesConfiguredFilters(item, options)).length, + total + + clientData.items + .filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist)) + .filter((item) => showCompletedItem(item, options)).length, 0, ); const totalDownloadSpeed = selectedClients.reduce( @@ -156,889 +328,1171 @@ const DownloadClientsMobileSummary = ({ })} /> ); -}; +} -const DownloadClientsTable = ({ +function DownloadClientsWidget({ isEditMode, integrationIds, options, setOptions, -}: WidgetComponentProps<"downloads">) => { - const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => - integrationIds.includes(id) ? [id] : [], + boardId, + itemId, + width, +}: WidgetComponentProps<"downloads">) { + const allInteractAccess = useIntegrationsWithInteractAccess(); + const integrationInteractSet = useMemo( + () => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)), + [allInteractAccess, integrationIds], ); - const { data: queryData, error: queryError } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ + const { + data: currentItems = [], + isFetching, + isError, + } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ integrationIds, limitPerIntegration: options.limitPerIntegration, }); - const currentItems = queryData ?? []; const t = useScopedI18n("widget.downloads"); - const tCommon = useScopedI18n("common"); - //Item modal state and selection - const [clickedIndex, setClickedIndex] = useState(0); - const [opened, { open, close }] = useDisclosure(false); - - //User quick settings for filters - const [quickFilters, setQuickFilters] = useState({ integrationKinds: [], statuses: [] }); - const availableStatuses = useMemo(() => { - //Redefine list of available statuses from current items - const statuses = Array.from(new Set(currentItems.flatMap(({ data }) => data.items.map(({ state }) => state)))); - //Reset user filters accordingly to remove unavailable statuses - setQuickFilters(({ integrationKinds: names, statuses: prevStatuses }) => { - return { integrationKinds: names, statuses: prevStatuses.filter((status) => statuses.includes(status)) }; - }); - return statuses; - }, [currentItems]); + 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() }; - 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 visibilityOptions = useMemo( - () => ({ - activeTorrentThreshold: options.activeTorrentThreshold, - categoryFilter: options.categoryFilter, - filterIsWhitelist: options.filterIsWhitelist, - showCompletedHttp: options.showCompletedHttp, - showCompletedTorrent: options.showCompletedTorrent, - showCompletedUsenet: options.showCompletedUsenet, - }), - [ - options.activeTorrentThreshold, - options.categoryFilter, - options.filterIsWhitelist, - options.showCompletedHttp, - options.showCompletedTorrent, - options.showCompletedUsenet, - ], - ); - - //Flatten Data array for which each element has its integration, data (base + calculated) and actions - const data = useMemo( - () => - currentItems - //Insure it is only using selected integrations - .filter(({ integration }) => integrationIds.includes(integration.id)) - //Construct normalized items list - .flatMap((pair) => - //Apply user white/black list - pair.data.items - .filter((item) => matchesConfiguredFilters(item, visibilityOptions)) - //Filter following user quick setting - .filter( - ({ state }) => - (quickFilters.integrationKinds.length === 0 || - quickFilters.integrationKinds.includes(pair.integration.name)) && - (quickFilters.statuses.length === 0 || quickFilters.statuses.includes(state)), - ) - //Add extrapolated data and actions if user is allowed interaction - .map((item): ExtendedDownloadClientItem => { - const received = item.received ?? Math.floor(item.size * item.progress); - const integrationIds = [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, - //Only add if permission to use mutations - actions: integrationsWithInteractions.includes(pair.integration.id) - ? { - resume: () => mutateResumeItem({ integrationIds, item }), - pause: () => mutatePauseItem({ integrationIds, item }), - delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds, item, fromDisk }), - } - : undefined, - }; - }), - ) - //flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent) - .sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length), - [ - currentItems, - integrationIds, - integrationsWithInteractions, - mutateDeleteItem, - mutatePauseItem, - mutateResumeItem, - quickFilters, - visibilityOptions, - ], - ); - - //Flatten Clients Array for which each elements has the integration and general client infos. - const clients = useMemo( - () => - currentItems - .filter(({ integration }) => integrationIds.includes(integration.id)) - .flatMap(({ integration, data }): ExtendedClientStatus => { - const interact = integrationsWithInteractions.includes(integration.id); - const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); - /** Derived from current items */ - const { totalUp, totalDown } = data.items - .filter( - ({ category }) => - !options.applyFilterToRatio || - !data.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, - ...data.status, - }, - }; - }) - .sort( - ({ status: statusA }, { status: statusB }) => - (statusA?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity), - ), - [ - currentItems, - integrationIds, - integrationsWithInteractions, - options.applyFilterToRatio, - options.categoryFilter, - options.filterIsWhitelist, - ], - ); - - //Check existing types between torrents and usenet - const integrationTypes: ExtendedDownloadClientItem["type"][] = []; - - if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent"); - if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet"); - if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous"); - - //Set the visibility of columns depending on widget settings and available data/integrations. - const columnVisibility: MRT_VisibilityState = { - id: options.columns.includes("id"), - actions: options.columns.includes("actions") && integrationsWithInteractions.length > 0, - added: options.columns.includes("added"), - category: options.columns.includes("category"), - downSpeed: options.columns.includes("downSpeed"), - index: options.columns.includes("index"), - integration: options.columns.includes("integration") && clients.length > 1, - name: options.columns.includes("name"), - progress: options.columns.includes("progress"), - ratio: options.columns.includes("ratio") && integrationTypes.includes("torrent"), - received: options.columns.includes("received"), - sent: options.columns.includes("sent") && integrationTypes.includes("torrent"), - size: options.columns.includes("size"), - state: options.columns.includes("state"), - time: options.columns.includes("time"), - type: options.columns.includes("type") && integrationTypes.length > 1, - upSpeed: options.columns.includes("upSpeed") && integrationTypes.includes("torrent"), - } satisfies Record; - - //Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header) - const editStyle: MantineStyleProp = { - pointerEvents: isEditMode ? "none" : undefined, + 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); - //Base element in common with all columns - const columnsDefBase = useCallback( - ({ - key, - showHeader, - }: { - key: keyof ExtendedDownloadClientItem; - showHeader: boolean; - }): MRT_ColumnDef => { - return { - id: key, - accessorKey: key, - header: key, - size: columnsRatios[key], - Header: () => - showHeader ? ( - - {t(`items.${key}.columnTitle`)} - - ) : null, - }; + const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ + onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), + }); + const persistOption = useCallback( + (newOptions: Partial>) => { + setOptions({ newOptions }); + if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions }); }, - [t], + [setOptions, boardId, itemId, saveItemOptions], ); - //Make columns and cell elements, Memoized to data with deps on data and EditMode - const columns = useMemo[]>( - () => [ - { - ...columnsDefBase({ key: "actions", showHeader: false }), - enableSorting: false, - Cell: ({ cell, row }) => { - const actions = cell.getValue(); - const pausedAction = row.original.state === "paused" ? "resume" : "pause"; - const [opened, { open, close }] = useDisclosure(false); + const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]); + const savedWidths = useMemo( + () => parseJsonOption(options.columnWidths, {} as Record), + [options.columnWidths], + ); - return actions ? ( - - - - {pausedAction === "resume" ? : } - - - - - - - - - - - - - - - - ) : ( - - - + let defaultSortDirection: DataTableSortStatus["direction"] = "asc"; + if (options.descendingDefaultSort) defaultSortDirection = "desc"; + const [sortStatus, setSortStatus] = useState>({ + columnAccessor: options.defaultSort, + direction: defaultSortDirection, + }); + useEffect(() => { + setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); + }, [options.defaultSort, defaultSortDirection]); + + const data = 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( + ({ 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]; + 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, + 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), [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) => ( + + + + ), }, - { - ...columnsDefBase({ key: "added", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const added = cell.getValue(); - return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; - }, - }, - { - ...columnsDefBase({ key: "category", showHeader: false }), - sortUndefined: "last", - Cell: ({ cell }) => { - const category = cell.getValue(); - return ( - category !== undefined && ( - - - - ) - ); - }, - }, - { - ...columnsDefBase({ key: "downSpeed", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const downSpeed = cell.getValue(); - return downSpeed ? {formatByteRate(downSpeed)} : null; - }, - }, - { - ...columnsDefBase({ key: "id", showHeader: false }), - enableSorting: false, - Cell: ({ cell }) => { - const id = cell.getValue(); - return ( - - - - ); - }, - }, - { - ...columnsDefBase({ key: "index", showHeader: true }), - Cell: ({ cell }) => { - const index = cell.getValue(); - return {index}; - }, - }, - { - ...columnsDefBase({ key: "integration", showHeader: false }), - Cell: ({ cell }) => { - const integration = cell.getValue(); - return ( - - - - ); - }, - }, - { - ...columnsDefBase({ key: "name", showHeader: true }), - Cell: ({ cell }) => { - const name = cell.getValue(); - return ( - - {name} - - ); - }, - }, - { - ...columnsDefBase({ key: "progress", showHeader: true }), - Cell: ({ cell, row }) => { - const progress = cell.getValue(); - return ( - - - {new Intl.NumberFormat("en", { - style: "percent", - notation: "compact", - unitDisplay: "narrow", - roundingMode: "floor", - }).format(progress)} - - ( + + + + + {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}%`} + + + + ); }, }, - { - ...columnsDefBase({ key: "ratio", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const ratio = cell.getValue(); - return ratio !== undefined && {ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}; + 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)}; }, }, - { - ...columnsDefBase({ key: "received", showHeader: true }), - Cell: ({ cell }) => { - const received = cell.getValue(); - return {formatBytes(received)}; + 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()}; }, }, - { - ...columnsDefBase({ key: "sent", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const sent = cell.getValue(); - return sent && {formatBytes(sent)}; + 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)}; }, }, - { - ...columnsDefBase({ key: "size", showHeader: true }), - Cell: ({ cell }) => { - const size = cell.getValue(); - return {formatBytes(size)}; + 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)}; }, }, - { - ...columnsDefBase({ key: "state", showHeader: true }), - enableSorting: false, - Cell: ({ cell }) => { - const state = cell.getValue(); - return {t(`states.${state}`)}; + 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} + + ); }, }, - { - ...columnsDefBase({ key: "time", showHeader: true }), - sortingFn: (rowA, rowB) => { - const timeA = rowA.getValue("time"); - const timeB = rowB.getValue("time"); - // Map time values to canonical sort keys: - // positive = ETA in ms (sort as-is, smaller = sooner) - // 0 = stalled/infinite (sort last in ascending = Infinity) - // negative = time since completion in ms (sort first in ascending = very negative) - const toSortKey = (time: number) => (time === 0 ? Infinity : time); - return toSortKey(timeA) - toSortKey(timeB); - }, - Cell: ({ cell }) => { - const time = cell.getValue(); - return time === 0 ? : {dayjs().add(time).fromNow()}; - }, + includeColumn("index") && { + accessor: "index", + title: t("items.index.columnTitle"), + sortable: true, + width: 40, + render: (record) => {record.index}, }, - { - ...columnsDefBase({ key: "type", showHeader: true }), - Cell: ({ cell }) => { - const type = cell.getValue(); - return {type}; - }, + includeColumn("type") && { + accessor: "type", + title: t("items.type.columnTitle"), + sortable: true, + width: 70, + render: (record) => ( + + {record.type} + + ), }, - { - ...columnsDefBase({ key: "upSpeed", showHeader: true }), - sortUndefined: "last", - Cell: ({ cell }) => { - const upSpeed = cell.getValue(); - return upSpeed && {formatByteRate(upSpeed)}; - }, - }, - ], - [columnsDefBase, t, tCommon], + ]; + return cols.filter(Boolean) as DataTableColumn[]; + }, [columnContext, t, size, progressColumnWidth]); + + const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; + const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = + useDataTableColumns({ + key: storeKey, + columns, + }); + + const lastStoreKey = useRef(storeKey); + const hydrated = useRef(false); + useEffect(() => { + if (lastStoreKey.current !== storeKey) { + lastStoreKey.current = storeKey; + hydrated.current = false; + } + if (hydrated.current) return; + if (savedOrder.length > 0) setColumnsOrder(savedOrder); + if (Object.keys(savedWidths).length > 0) { + setMultipleColumnWidths(Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w }))); + } + // ponytail: defer hydrated flag so the persist effect skips the restore echo + requestAnimationFrame(() => { + hydrated.current = true; + }); + }, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); + + const prevOrder = useRef(columnsOrder); + const prevWidths = useRef(columnsWidth); + useEffect(() => { + if (!hydrated.current) return; + const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); + const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); + prevOrder.current = columnsOrder; + prevWidths.current = columnsWidth; + + if (!orderChanged && !widthsChanged) return; + if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); + if (widthsChanged) { + const widthMap: Record = {}; + for (const entry of columnsWidth) { + const key = Object.keys(entry)[0]; + if (!key) continue; + const w = entry[key as keyof typeof entry]; + if (typeof w === "number") widthMap[key] = w; + else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); + } + persistOption({ columnWidths: JSON.stringify(widthMap) }); + } + }, [columnsOrder, columnsWidth, persistOption]); + + const handleContextMenu = useCallback( + ({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, item: record }); + }, + [], ); - //Table build and config - const table = useMantineReactTable({ - columns, - data, - enablePagination: false, - enableTopToolbar: false, - enableBottomToolbar: false, - enableColumnActions: false, - enableSorting: options.enableRowSorting && !isEditMode, - enableMultiSort: true, - enableStickyHeader: false, - enableColumnOrdering: isEditMode, - enableRowVirtualization: true, - rowVirtualizerOptions: { overscan: 5 }, - mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" }, - mantineTableContainerProps: { style: { height: "100%" } }, - mantineTableProps: { - className: "downloads-widget-table", - }, - mantineTableBodyProps: { style: editStyle }, - mantineTableHeadCellProps: { - p: 4, - }, - mantineTableBodyCellProps: ({ cell, row }) => ({ - onClick: () => { - setClickedIndex(row.index); - if (cell.column.id !== "actions") open(); - }, - p: 4, - }), - onColumnOrderChange: (order) => { - //Order has a tendency to add the disabled column at the end of the the real ordered array - const columnOrder = (order as typeof options.columns).filter((column) => options.columns.includes(column)); - setOptions({ newOptions: { columns: columnOrder } }); - }, - initialState: { - sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }], - columnVisibility: { - actions: false, - added: false, - category: false, - downSpeed: false, - id: false, - index: false, - integration: false, - name: false, - progress: false, - ratio: false, - received: false, - sent: false, - size: false, - state: false, - time: false, - type: false, - upSpeed: false, - } satisfies Record, - columnOrder: options.columns, - }, - state: { - columnVisibility, - columnOrder: options.columns, - }, - }); + const closeContextMenu = useCallback(() => setContextMenu(null), []); - //Used for Global Torrent Ratio - const globalTraffic = clients - .filter(({ integration: { kind } }) => - getIntegrationKindsByCategory("torrent").some((integrationKind) => integrationKind === kind), - ) - .reduce( - ({ up, down }, { status }) => ({ - up: up + (status?.totalUp ?? 0), - down: down + (status?.totalDown ?? 0), - }), - { up: 0, down: 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 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]); - throwOnInitialQueryError(queryError, queryData !== undefined); - const hasWarning = hasWidgetDataWarning({ - error: queryError, - expectedIntegrationCount: integrationIds.length, - receivedIntegrationCount: currentItems.length, - }); + const availableStatuses = useMemo(() => [...new Set(data.map(({ state }) => state))], [data]); - if (options.columns.length === 0) + 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")} -
-
+
+ {t("errors.noColumns")} +
); + } - //The actual widget - const content = ( - - - - {integrationTypes.includes("torrent") && ( - - {`${t("globalRatio")}:`} - {(globalTraffic.up / globalTraffic.down).toFixed(2)} - - )} - + {t("errors.noCommunications")} + + ); + } + + let tablePointerEvents: React.CSSProperties["pointerEvents"]; + if (isEditMode) tablePointerEvents = "none"; + + let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu; + if (isEditMode) rowContextMenuHandler = undefined; + + return ( + + {showStats && ( + - - + )} + + + `${record.integration.id}:${record.id}`} + height="100%" + className="downloads-table" + defaultColumnProps={{ + noWrap: true, + draggable: true, + resizable: true, + cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), + }} + scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} + 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(); + }} + /> + + + + + {contextMenu && } ); - return {content}; -}; - -interface ItemInfoModalProps { - items: ExtendedDownloadClientItem[]; - currentIndex: number; - opened: boolean; - onClose: () => void; } -const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => { - const item = useMemo(() => items[currentIndex], [items, currentIndex]); - const t = useScopedI18n("widget.downloads.states"); - //The use case for "No item found" should be impossible, hence no translation +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 ( - - {item === undefined ? ( -
{"No item found"}
- ) : ( - - {item.name} - - - {`${item.integration.name} (${item.integration.kind})`} - - - - - {item.type !== "miscellaneous" && ( - - )} - - - - {item.type !== "miscellaneous" && ( - - )} - - - - - {item.type !== "miscellaneous" && } - - - - - )} -
+ + + {record.name} + + + {lines.map(({ label, value }) => ( + + + {label} + + + {value} + + + ))} + ); -}; +} + +function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) { + const t = useScopedI18n("widget.downloads"); + 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(); -const NormalizedLine = ({ - itemKey, - values, -}: { - itemKey: Exclude; - values?: number | string | string[]; -}) => { - const t = useScopedI18n("widget.downloads.items"); - if (typeof values !== "number" && (values === undefined || values.length === 0)) return null; return ( - - {`${t(`${itemKey}.detailsTitle`)}:`} - {Array.isArray(values) ? ( - - {values.map((value) => ( - {value} - ))} + + + {`${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 && } + + - ) : ( - {values} - )} + + + ); +} + +function DetailPair({ + label, + value, + icon, + color, +}: { + label: string; + value: string; + icon?: React.ReactNode; + color?: string; +}) { + return ( + + + {label}: + + + {icon} + + {value} + + ); -}; - -interface ClientsControlProps { - clients: ExtendedClientStatus[]; - filters: QuickFilter; - setFilters: (filters: QuickFilter) => void; - availableStatuses: QuickFilter["statuses"]; } -const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: ClientsControlProps) => { - 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[] }, - ); - const someInteract = clients.some(({ interact }) => interact); - const totalSpeed = formatByteRate(clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0)); - - const utils = clientApi.useUtils(); - const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; - const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads); - const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); - const [opened, { open, close }] = useDisclosure(false); +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 tCommon = useScopedI18n("common"); + + let overallProgress = 0; + if (queueStats.totalSize > 0) overallProgress = queueStats.completedSize / queueStats.totalSize; + const overallProgressPct = Math.floor(overallProgress * 100); + return ( - - - - - - - - - - {t("items.integration.columnTitle")} - setFilters({ ...filters, integrationKinds: names })} - > - {clients.map(({ integration }) => ( - - {integration.name} - - ))} - - {t("items.state.columnTitle")} - setFilters({ ...filters, statuses: statuses })} - > - {availableStatuses.map((status) => ( - - {t(`states.${status}`)} - - ))} - - - - - - {clients.map((client) => ( - - ))} - - {someInteract && ( - - mutateResumeQueue({ integrationIds: integrationsStatuses.paused })} - > - - - - )} - - {someInteract && ( - - mutatePauseQueue({ integrationIds: integrationsStatuses.active })} - > - - - - )} - - - {clients.map((client) => ( - - - - - - - {client.status ? ( - - - {client.status.rates.up !== undefined ? ( - - - {`↑ ${formatByteRate(client.status.rates.up)}`} - - {"-"} - - {formatBytes(client.status.totalUp ?? 0)} - - - ) : undefined} - - - {`↓ ${formatByteRate(client.status.rates.down)}`} - - {"-"} - - {formatBytes(Math.floor(client.status.totalDown ?? 0))} - - - - - ) : ( - - {t("errors.noCommunications")} - - )} - - - - {client.integration.name} - - - {client.status && client.interact ? ( - - + + + + + {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={() => { - (client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({ - integrationIds: [client.integration.id], - }); + item.actions?.delete({ fromDisk: false }); + onClose(); }} > - {client.status.paused ? : } - - - ) : ( - - - + {t("actions.item.delete.entry")} + + } + onClick={() => { + item.actions?.delete({ fromDisk: true }); + onClose(); + }} + > + {t("actions.item.delete.entryAndFiles")} + + )} - - - ))} - - - + + )} + + +
+ ); -}; - -interface ClientAvatarProps { - client: ExtendedClientStatus; } -const ClientAvatar = ({ client }: ClientAvatarProps) => { - const isConnected = useIntegrationConnected(client.integration.updatedAt, { timeout: 30000 }); +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 ( + + + + ); +} diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index d52dde1d9..5daba3085 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -8,28 +8,37 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; const columnsList = [ - "id", - "actions", - "added", - "category", - "downSpeed", - "index", - "integration", "name", "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", "ratio", "received", "sent", - "size", - "state", - "time", + "category", + "integration", + "index", "type", - "upSpeed", ] as const satisfies (keyof ExtendedDownloadClientItem)[]; -const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][]; -const columnsSort = columnsList.filter((column) => - sortingExclusion.every((exclusion) => exclusion !== column), -) as Exclude; + +const sortColumns = [ + "name", + "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "added", + "ratio", + "received", + "sent", + "index", + "type", +] as const satisfies readonly (typeof columnsList)[number][]; export const { definition, componentLoader } = createWidgetDefinition("downloads", { icon: IconDownload, @@ -46,19 +55,16 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads return optionsBuilder.from( (factory) => ({ columns: factory.multiSelect({ - defaultValue: ["integration", "name", "progress", "time", "actions"], + defaultValue: ["name", "progress", "downSpeed", "time", "state"], options: columnsList.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), searchable: true, }), - enableRowSorting: factory.switch({ - defaultValue: false, - }), defaultSort: factory.select({ - defaultValue: "type", - options: columnsSort.map((value) => ({ + defaultValue: "progress", + options: sortColumns.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), @@ -76,7 +82,6 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads defaultValue: true, }), activeTorrentThreshold: factory.number({ - //in KiB/s validate: z.number().min(0), defaultValue: 0, step: 1, @@ -96,14 +101,12 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads validate: z.number().min(1), withDescription: true, }), + columnOrder: factory.text({ defaultValue: "" }), + columnWidths: factory.text({ defaultValue: "" }), }), { - defaultSort: { - shouldHide: (options) => !options.enableRowSorting, - }, - descendingDefaultSort: { - shouldHide: (options) => !options.enableRowSorting, - }, + columnOrder: { shouldHide: () => true }, + columnWidths: { shouldHide: () => true }, showCompletedUsenet: { shouldHide: (_, integrationKinds) => !getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)), diff --git a/packages/widgets/src/downloads/styles.css b/packages/widgets/src/downloads/styles.css index ae9bee8c1..98991c9f2 100644 --- a/packages/widgets/src/downloads/styles.css +++ b/packages/widgets/src/downloads/styles.css @@ -1,12 +1,52 @@ -/* Make the MantineReactTable transparent so the board card's opacity-controlled - background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */ -.downloads-widget-table, -.downloads-widget-table .mantine-Table-table, -.downloads-widget-table .mantine-Table-thead, -.downloads-widget-table .mantine-Table-tbody, -.downloads-widget-table .mantine-Table-tfoot, -.downloads-widget-table .mantine-Table-tr, -.downloads-widget-table th, -.downloads-widget-table td { +/* Make the DataTable transparent so the board card's opacity-controlled + background shows through. The mantine-datatable defaults to + var(--mantine-color-body) which is opaque. */ +.downloads-table, +.downloads-table .mantine-datatable-table, +.downloads-table .mantine-datatable-table thead, +.downloads-table .mantine-datatable-table tbody, +.downloads-table .mantine-datatable-table tfoot, +.downloads-table th, +.downloads-table td { background-color: transparent !important; } + +.downloads-table .mantine-datatable-table tr { + background-color: transparent; +} + +.downloads-table .mantine-datatable-table tbody tr:hover { + background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; +} + +.downloads-table th { + white-space: nowrap; + background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; + backdrop-filter: blur(8px); +} + +.downloads-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.downloads-table td .mantine-Text-root, +.downloads-table td .mantine-Group-root { + white-space: nowrap; + flex-wrap: nowrap; +} + +.downloads-table .mantine-datatable-header-cell-sortable-icon { + opacity: 0; + transition: opacity 150ms; +} + +.downloads-table th:hover .mantine-datatable-header-cell-sortable-icon { + opacity: 1; +} + +/* Row expansion styling */ +.downloads-table .mantine-datatable-row-expansion-cell { + background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; +}