From ad053d6656faa3cbe4b483c1aa451f6d9cde2a23 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 29 Aug 2024 13:50:26 -0400 Subject: [PATCH 01/14] Add TablePage component --- src/apps/dashboard/components/TablePage.tsx | 46 ++++++++++++++++++++ src/apps/dashboard/routes/activity/index.tsx | 32 +++----------- src/components/Page.tsx | 6 ++- 3 files changed, 56 insertions(+), 28 deletions(-) create mode 100644 src/apps/dashboard/components/TablePage.tsx diff --git a/src/apps/dashboard/components/TablePage.tsx b/src/apps/dashboard/components/TablePage.tsx new file mode 100644 index 0000000000..2061730996 --- /dev/null +++ b/src/apps/dashboard/components/TablePage.tsx @@ -0,0 +1,46 @@ +import Box from '@mui/material/Box/Box'; +import Typography from '@mui/material/Typography/Typography'; +import { type MRT_RowData, type MRT_TableInstance, MaterialReactTable } from 'material-react-table'; +import React from 'react'; + +import Page, { type PageProps } from 'components/Page'; + +interface TablePageProps extends PageProps { + title: string + table: MRT_TableInstance +} + +const TablePage = ({ + title, + table, + ...pageProps +}: TablePageProps) => { + return ( + + + + + {title} + + + + + + ); +}; + +export default TablePage; diff --git a/src/apps/dashboard/routes/activity/index.tsx b/src/apps/dashboard/routes/activity/index.tsx index 5b0e328777..2b424c9e5a 100644 --- a/src/apps/dashboard/routes/activity/index.tsx +++ b/src/apps/dashboard/routes/activity/index.tsx @@ -2,11 +2,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; -import Box from '@mui/material/Box'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Typography from '@mui/material/Typography'; -import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table'; +import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import { useSearchParams } from 'react-router-dom'; import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; @@ -15,12 +13,13 @@ import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCe import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell'; import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell'; -import Page from 'components/Page'; import { useUsers } from 'hooks/useUsers'; import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import globalize from 'lib/globalize'; import { toBoolean } from 'utils/string'; +import TablePage from '../components/TablePage'; + type UsersRecords = Record; const DEFAULT_PAGE_SIZE = 25; @@ -229,31 +228,12 @@ const Activity = () => { }); return ( - - - - - {globalize.translate('HeaderActivity')} - - - - - + table={table} + /> ); }; diff --git a/src/components/Page.tsx b/src/components/Page.tsx index 6787e8f545..f3f9db7249 100644 --- a/src/components/Page.tsx +++ b/src/components/Page.tsx @@ -2,7 +2,7 @@ import React, { type FC, type PropsWithChildren, type HTMLAttributes, useEffect, import viewManager from './viewManager/viewManager'; -type PageProps = { +type CustomPageProps = { id: string, // id is required for libraryMenu title?: string, isBackButtonEnabled?: boolean, @@ -12,11 +12,13 @@ type PageProps = { backDropType?: string, }; +export type PageProps = CustomPageProps & HTMLAttributes; + /** * Page component that handles hiding active non-react views, triggering the required events for * navigation and appRouter state updates, and setting the correct classes and data attributes. */ -const Page: FC>> = ({ +const Page: FC> = ({ children, id, className = '', From 9c0aa85c46d39dbbe4c5d7085626f45a6d7e5aed Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 30 Aug 2024 10:10:58 -0400 Subject: [PATCH 02/14] Rewrite devices page in react --- .../drawer/sections/DevicesDrawerSection.tsx | 8 + .../features/devices/api/useDevices.ts | 36 ++++ src/apps/dashboard/routes/_asyncRoutes.ts | 1 + src/apps/dashboard/routes/devices/index.tsx | 166 ++++++++++++++++++ src/strings/en-us.json | 1 + 5 files changed, 212 insertions(+) create mode 100644 src/apps/dashboard/features/devices/api/useDevices.ts create mode 100644 src/apps/dashboard/routes/devices/index.tsx diff --git a/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx index 18fcb010e8..b8ea68e708 100644 --- a/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx @@ -27,6 +27,14 @@ const DevicesDrawerSection = () => { + + + + + + + + diff --git a/src/apps/dashboard/features/devices/api/useDevices.ts b/src/apps/dashboard/features/devices/api/useDevices.ts new file mode 100644 index 0000000000..8335f90876 --- /dev/null +++ b/src/apps/dashboard/features/devices/api/useDevices.ts @@ -0,0 +1,36 @@ +import type { DevicesApiGetDevicesRequest } from '@jellyfin/sdk/lib/generated-client'; +import type { AxiosRequestConfig } from 'axios'; +import type { Api } from '@jellyfin/sdk'; +import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api'; +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'hooks/useApi'; + +const fetchDevices = async ( + api?: Api, + requestParams?: DevicesApiGetDevicesRequest, + options?: AxiosRequestConfig +) => { + if (!api) { + console.warn('[fetchDevices] No API instance available'); + return; + } + + const response = await getDevicesApi(api).getDevices(requestParams, { + signal: options?.signal + }); + + return response.data; +}; + +export const useDevices = ( + requestParams: DevicesApiGetDevicesRequest +) => { + const { api } = useApi(); + return useQuery({ + queryKey: ['Devices', requestParams], + queryFn: ({ signal }) => + fetchDevices(api, requestParams, { signal }), + enabled: !!api + }); +}; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index eb42010cf3..0cda4e2595 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -4,6 +4,7 @@ import { AppType } from 'constants/appType'; export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'activity', type: AppType.Dashboard }, { path: 'branding', type: AppType.Dashboard }, + { path: 'devices2', page: 'devices', type: AppType.Dashboard }, { path: 'keys', type: AppType.Dashboard }, { path: 'logs', type: AppType.Dashboard }, { path: 'playback/trickplay', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx new file mode 100644 index 0000000000..b8b1b91aa4 --- /dev/null +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -0,0 +1,166 @@ +import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import Box from '@mui/material/Box/Box'; +import Button from '@mui/material/Button/Button'; +import IconButton from '@mui/material/IconButton/IconButton'; +import Tooltip from '@mui/material/Tooltip/Tooltip'; +import React, { useMemo } from 'react'; + +import TablePage from 'apps/dashboard/components/TablePage'; +import { useDevices } from 'apps/dashboard/features/devices/api/useDevices'; +import globalize from 'lib/globalize'; +import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; +import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; +import { useApi } from 'hooks/useApi'; +import { getDeviceIcon } from 'utils/image'; +import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; +import { useUsers } from 'hooks/useUsers'; +import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; + +type UsersRecords = Record; + +const DevicesPage = () => { + const { api } = useApi(); + const { data: devices, isLoading: isDevicesLoading } = useDevices({}); + const { data: usersData, isLoading: isUsersLoading } = useUsers(); + + const isLoading = isDevicesLoading || isUsersLoading; + + const users: UsersRecords = useMemo(() => { + if (!usersData) return {}; + + return usersData.reduce((acc, user) => { + const userId = user.Id; + if (!userId) return acc; + + return { + ...acc, + [userId]: user + }; + }, {}); + }, [ usersData ]); + + const columns = useMemo[]>(() => [ + { + id: 'DateLastActivity', + accessorFn: row => parseISO8601Date(row.DateLastActivity), + header: globalize.translate('LabelTime'), + size: 160, + Cell: ({ cell }) => toLocaleString(cell.getValue()), + filterVariant: 'datetime-range', + enableEditing: false + }, + { + id: 'Name', + accessorFn: row => row.CustomName || row.Name, + header: globalize.translate('LabelDevice'), + size: 200, + Cell: ({ row, renderedCellValue }) => ( + <> + {row.original.AppName + {renderedCellValue} + + ) + }, + { + id: 'App', + accessorFn: row => [row.AppName, row.AppVersion] + .filter(v => !!v) // filter missing values + .join(' '), + header: globalize.translate('LabelAppName'), + size: 200, + enableEditing: false + }, + { + accessorKey: 'LastUserName', + header: globalize.translate('LabelUser'), + size: 120, + enableEditing: false, + Cell: ({ row, renderedCellValue }) => ( + <> + + {renderedCellValue} + + ) + } + ], [ users ]); + + const mrTable = useMaterialReactTable({ + columns, + data: devices?.Items || [], + + // Enable custom features + enableColumnPinning: true, + enableColumnResizing: true, + enableEditing: true, + + // Sticky header/footer + enableStickyFooter: true, + enableStickyHeader: true, + muiTableContainerProps: { + sx: { + maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer + } + }, + + // State + initialState: { + pagination: { + pageIndex: 0, + pageSize: 25 + } + }, + state: { + isLoading + }, + + // Custom actions + enableRowActions: true, + positionActionsColumn: 'last', + renderRowActions: ({ row, table }) => ( + + + table.setEditingRow(row)} + > + + + + + + + + + + ), + + // Custom toolbar contents + renderTopToolbarCustomActions: () => ( + + ) + }); + + return ( + + ); +}; + +export default DevicesPage; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 9c69f90285..8770151ff2 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -661,6 +661,7 @@ "LabelDelimiterWhitelist": "Delimiter Whitelist", "LabelDelimiterWhitelistHelp": "Items to be excluded from tag splitting. One item per line.", "LabelDeveloper": "Developer", + "LabelDevice": "Device", "LabelDisableCustomCss": "Disable custom CSS code for theming/branding provided from the server.", "LabelDisableVbrAudioEncoding": "Disable VBR audio encoding", "LabelDiscNumber": "Disc number", From 538c0b64ff9452c2c3cbb74ef6898951809da783 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Sun, 26 Jan 2025 01:11:30 -0500 Subject: [PATCH 03/14] Update import for TablePage --- src/apps/dashboard/routes/activity/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/apps/dashboard/routes/activity/index.tsx b/src/apps/dashboard/routes/activity/index.tsx index 2b424c9e5a..a9734c774b 100644 --- a/src/apps/dashboard/routes/activity/index.tsx +++ b/src/apps/dashboard/routes/activity/index.tsx @@ -7,6 +7,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import { useSearchParams } from 'react-router-dom'; +import TablePage from 'apps/dashboard/components/TablePage'; import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell'; import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell'; @@ -18,8 +19,6 @@ import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import globalize from 'lib/globalize'; import { toBoolean } from 'utils/string'; -import TablePage from '../components/TablePage'; - type UsersRecords = Record; const DEFAULT_PAGE_SIZE = 25; From e10aef993379321a65e9d7f793d778d2fbc5bed1 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 12:43:55 -0500 Subject: [PATCH 04/14] Add device deletion support --- src/apps/dashboard/components/TablePage.tsx | 17 ++ .../activity/components/UserAvatarButton.tsx | 12 +- .../features/devices/api/useDeleteDevice.ts | 24 ++ .../features/devices/api/useDevices.ts | 4 +- .../features/devices/api/useUpdateDevice.ts | 24 ++ src/apps/dashboard/routes/activity/index.tsx | 46 +--- src/apps/dashboard/routes/devices/index.tsx | 254 ++++++++++++------ src/hooks/useUsers.ts | 25 +- 8 files changed, 286 insertions(+), 120 deletions(-) create mode 100644 src/apps/dashboard/features/devices/api/useDeleteDevice.ts create mode 100644 src/apps/dashboard/features/devices/api/useUpdateDevice.ts diff --git a/src/apps/dashboard/components/TablePage.tsx b/src/apps/dashboard/components/TablePage.tsx index 2061730996..4e5daef2a5 100644 --- a/src/apps/dashboard/components/TablePage.tsx +++ b/src/apps/dashboard/components/TablePage.tsx @@ -10,9 +10,25 @@ interface TablePageProps extends PageProps { table: MRT_TableInstance } +export const DEFAULT_TABLE_OPTIONS = { + // Enable custom features + enableColumnPinning: true, + enableColumnResizing: true, + + // Sticky header/footer + enableStickyFooter: true, + enableStickyHeader: true, + muiTableContainerProps: { + sx: { + maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer + } + } +}; + const TablePage = ({ title, table, + children, ...pageProps }: TablePageProps) => { return ( @@ -39,6 +55,7 @@ const TablePage = ({ + {children} ); }; diff --git a/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx b/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx index 91f126e92c..e8f4530de3 100644 --- a/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx +++ b/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx @@ -1,4 +1,5 @@ import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; +import type { SxProps, Theme } from '@mui/material'; import IconButton from '@mui/material/IconButton/IconButton'; import React, { type FC } from 'react'; import { Link } from 'react-router-dom'; @@ -7,14 +8,21 @@ import UserAvatar from 'components/UserAvatar'; interface UserAvatarButtonProps { user?: UserDto + sx?: SxProps } -const UserAvatarButton: FC = ({ user }) => ( +const UserAvatarButton: FC = ({ + user, + sx +}) => ( user?.Id ? ( { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: DevicesApiDeleteDeviceRequest) => ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getDevicesApi(api!) + .deleteDevice(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/devices/api/useDevices.ts b/src/apps/dashboard/features/devices/api/useDevices.ts index 8335f90876..782383078f 100644 --- a/src/apps/dashboard/features/devices/api/useDevices.ts +++ b/src/apps/dashboard/features/devices/api/useDevices.ts @@ -6,6 +6,8 @@ import { useQuery } from '@tanstack/react-query'; import { useApi } from 'hooks/useApi'; +export const QUERY_KEY = 'Devices'; + const fetchDevices = async ( api?: Api, requestParams?: DevicesApiGetDevicesRequest, @@ -28,7 +30,7 @@ export const useDevices = ( ) => { const { api } = useApi(); return useQuery({ - queryKey: ['Devices', requestParams], + queryKey: [QUERY_KEY, requestParams], queryFn: ({ signal }) => fetchDevices(api, requestParams, { signal }), enabled: !!api diff --git a/src/apps/dashboard/features/devices/api/useUpdateDevice.ts b/src/apps/dashboard/features/devices/api/useUpdateDevice.ts new file mode 100644 index 0000000000..740c5ca67b --- /dev/null +++ b/src/apps/dashboard/features/devices/api/useUpdateDevice.ts @@ -0,0 +1,24 @@ +import type { DevicesApiUpdateDeviceOptionsRequest } from '@jellyfin/sdk/lib/generated-client/api/devices-api'; +import { getDevicesApi } from '@jellyfin/sdk/lib/utils/api/devices-api'; +import { useMutation } from '@tanstack/react-query'; + +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; +import { QUERY_KEY } from './useDevices'; + +export const useUpdateDevice = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: DevicesApiUpdateDeviceOptionsRequest) => ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + getDevicesApi(api!) + .updateDeviceOptions(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }); +}; diff --git a/src/apps/dashboard/routes/activity/index.tsx b/src/apps/dashboard/routes/activity/index.tsx index a9734c774b..2793ff4fe1 100644 --- a/src/apps/dashboard/routes/activity/index.tsx +++ b/src/apps/dashboard/routes/activity/index.tsx @@ -1,26 +1,23 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; -import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import { useSearchParams } from 'react-router-dom'; -import TablePage from 'apps/dashboard/components/TablePage'; +import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage'; import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell'; import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell'; import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell'; import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell'; -import { useUsers } from 'hooks/useUsers'; +import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import globalize from 'lib/globalize'; import { toBoolean } from 'utils/string'; -type UsersRecords = Record; - const DEFAULT_PAGE_SIZE = 25; const VIEW_PARAM = 'useractivity'; @@ -53,29 +50,7 @@ const Activity = () => { pageSize: DEFAULT_PAGE_SIZE }); - const { data: usersData, isLoading: isUsersLoading } = useUsers(); - - const users: UsersRecords = useMemo(() => { - if (!usersData) return {}; - - return usersData.reduce((acc, user) => { - const userId = user.Id; - if (!userId) return acc; - - return { - ...acc, - [userId]: user - }; - }, {}); - }, [ usersData ]); - - const userNames = useMemo(() => { - const names: string[] = []; - usersData?.forEach(user => { - if (user.Name) names.push(user.Name); - }); - return names; - }, [ usersData ]); + const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails(); const UserCell = getUserCell(users); @@ -175,22 +150,11 @@ const Activity = () => { }, [ activityView, searchParams, setSearchParams ]); const table = useMaterialReactTable({ + ...DEFAULT_TABLE_OPTIONS, + columns, data: logEntries?.Items || [], - // Enable custom features - enableColumnPinning: true, - enableColumnResizing: true, - - // Sticky header/footer - enableStickyFooter: true, - enableStickyHeader: true, - muiTableContainerProps: { - sx: { - maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer - } - }, - // State initialState: { density: 'compact' diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index b8b1b91aa4..e8ad8bf074 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -5,41 +5,113 @@ import Box from '@mui/material/Box/Box'; import Button from '@mui/material/Button/Button'; import IconButton from '@mui/material/IconButton/IconButton'; import Tooltip from '@mui/material/Tooltip/Tooltip'; -import React, { useMemo } from 'react'; +import React, { FC, useCallback, useMemo, useState } from 'react'; -import TablePage from 'apps/dashboard/components/TablePage'; +import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage'; import { useDevices } from 'apps/dashboard/features/devices/api/useDevices'; import globalize from 'lib/globalize'; -import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; +import { type MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table'; import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import { useApi } from 'hooks/useApi'; import { getDeviceIcon } from 'utils/image'; import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; -import { useUsers } from 'hooks/useUsers'; -import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; +import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; +import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice'; +import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice'; +import ConfirmDialog from 'components/ConfirmDialog'; -type UsersRecords = Record; +interface DeviceInfoCell { + renderedCellValue: React.ReactNode + row: MRT_Row +} + +const DeviceNameCell: FC = ({ row, renderedCellValue }) => ( + <> + {row.original.AppName + {renderedCellValue} + +); + +const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) { + return ( + <> + + {renderedCellValue} + + ); +}; const DevicesPage = () => { const { api } = useApi(); const { data: devices, isLoading: isDevicesLoading } = useDevices({}); - const { data: usersData, isLoading: isUsersLoading } = useUsers(); + const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails(); + + const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false); + const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false); + const [ pendingDeleteDeviceId, setPendingDeleteDeviceId ] = useState(); + const deleteDevice = useDeleteDevice(); + const updateDevice = useUpdateDevice(); const isLoading = isDevicesLoading || isUsersLoading; - const users: UsersRecords = useMemo(() => { - if (!usersData) return {}; + const onDeleteDevice = useCallback((id: string | null | undefined) => () => { + if (id) { + setPendingDeleteDeviceId(id); + setIsDeleteConfirmOpen(true); + } + }, []); - return usersData.reduce((acc, user) => { - const userId = user.Id; - if (!userId) return acc; + const onCloseDeleteConfirmDialog = useCallback(() => { + setPendingDeleteDeviceId(undefined); + setIsDeleteConfirmOpen(false); + }, []); - return { - ...acc, - [userId]: user - }; - }, {}); - }, [ usersData ]); + const onConfirmDelete = useCallback(() => { + if (pendingDeleteDeviceId) { + deleteDevice.mutate({ + id: pendingDeleteDeviceId + }, { + onSettled: onCloseDeleteConfirmDialog + }); + } + }, [ deleteDevice, onCloseDeleteConfirmDialog, pendingDeleteDeviceId ]); + + const onDeleteAll = useCallback(() => { + setIsDeleteAllConfirmOpen(true); + }, []); + + const onCloseDeleteAllConfirmDialog = useCallback(() => { + setIsDeleteAllConfirmOpen(false); + }, []); + + const onConfirmDeleteAll = useCallback(() => { + if (devices?.Items) { + Promise + .all(devices.Items.map(item => { + if (api && item.Id && api.deviceInfo.id === item.Id) { + return deleteDevice.mutateAsync({ id: item.Id }); + } + return Promise.resolve(); + })) + .finally(() => { + onCloseDeleteAllConfirmDialog(); + }); + } + }, [ api, deleteDevice, devices?.Items, onCloseDeleteAllConfirmDialog ]); + + const UserCell = getUserCell(users); const columns = useMemo[]>(() => [ { @@ -56,21 +128,7 @@ const DevicesPage = () => { accessorFn: row => row.CustomName || row.Name, header: globalize.translate('LabelDevice'), size: 200, - Cell: ({ row, renderedCellValue }) => ( - <> - {row.original.AppName - {renderedCellValue} - - ) + Cell: DeviceNameCell }, { id: 'App', @@ -86,35 +144,21 @@ const DevicesPage = () => { header: globalize.translate('LabelUser'), size: 120, enableEditing: false, - Cell: ({ row, renderedCellValue }) => ( - <> - - {renderedCellValue} - - ) + Cell: UserCell, + filterVariant: 'multi-select', + filterSelectOptions: userNames } - ], [ users ]); + ], [ UserCell, userNames ]); const mrTable = useMaterialReactTable({ + ...DEFAULT_TABLE_OPTIONS, + columns, data: devices?.Items || [], - // Enable custom features - enableColumnPinning: true, - enableColumnResizing: true, - enableEditing: true, - - // Sticky header/footer - enableStickyFooter: true, - enableStickyHeader: true, - muiTableContainerProps: { - sx: { - maxHeight: 'calc(100% - 7rem)' // 2 x 3.5rem for header and footer - } - }, - // State initialState: { + density: 'compact', pagination: { pageIndex: 0, pageSize: 25 @@ -124,32 +168,73 @@ const DevicesPage = () => { isLoading }, + // Editing device name + enableEditing: true, + onEditingRowSave: ({ table, row, values }) => { + const newName = values.Name?.trim(); + const hasChanged = row.original.CustomName ? + newName !== row.original.CustomName : + newName !== row.original.Name; + + // If the name has changed, save it as the custom name + if (row.original.Id && hasChanged) { + updateDevice.mutate({ + id: row.original.Id, + deviceOptionsDto: { + CustomName: newName || undefined + } + }); + } + + table.setEditingRow(null); //exit editing mode + }, + // Custom actions enableRowActions: true, positionActionsColumn: 'last', - renderRowActions: ({ row, table }) => ( - - - table.setEditingRow(row)} - > - - - - - - - - - - ), + renderRowActions: ({ row, table }) => { + const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id; + return ( + + + table.setEditingRow(row)} + > + + + + {/* Don't include Tooltip when disabled */} + {isDeletable ? ( + + + + ) : ( + + + + + + )} + + ); + }, // Custom toolbar contents renderTopToolbarCustomActions: () => ( - + ) }); @@ -159,7 +244,26 @@ const DevicesPage = () => { title={globalize.translate('HeaderDevices')} className='mainAnimatedPage type-interior' table={mrTable} - /> + > + + + ); }; diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index cc62d6b2c3..3e9d1f3b6f 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -1,11 +1,13 @@ import type { AxiosRequestConfig } from 'axios'; import type { Api } from '@jellyfin/sdk'; -import type { UserApiGetUsersRequest } from '@jellyfin/sdk/lib/generated-client'; +import type { UserApiGetUsersRequest, UserDto } from '@jellyfin/sdk/lib/generated-client'; import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api'; import { useQuery } from '@tanstack/react-query'; import { useApi } from './useApi'; +export type UsersRecords = Record; + const fetchUsers = async ( api?: Api, requestParams?: UserApiGetUsersRequest, @@ -32,3 +34,24 @@ export const useUsers = (requestParams?: UserApiGetUsersRequest) => { enabled: !!api }); }; + +export const useUsersDetails = () => { + const { data: users, ...rest } = useUsers(); + const usersById: UsersRecords = {}; + const names: string[] = []; + + if (users) { + users.forEach(user => { + const userId = user.Id; + if (userId) usersById[userId] = user; + if (user.Name) names.push(user.Name); + }); + } + + return { + users, + usersById, + names, + ...rest + }; +}; From e7c749307cde9c82db565760f8f368d568c2ed64 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 12:49:51 -0500 Subject: [PATCH 05/14] Move UserAvatarButton component --- .../{features/activity => }/components/UserAvatarButton.tsx | 0 src/apps/dashboard/routes/activity/index.tsx | 2 +- src/apps/dashboard/routes/devices/index.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/apps/dashboard/{features/activity => }/components/UserAvatarButton.tsx (100%) diff --git a/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx b/src/apps/dashboard/components/UserAvatarButton.tsx similarity index 100% rename from src/apps/dashboard/features/activity/components/UserAvatarButton.tsx rename to src/apps/dashboard/components/UserAvatarButton.tsx diff --git a/src/apps/dashboard/routes/activity/index.tsx b/src/apps/dashboard/routes/activity/index.tsx index 2793ff4fe1..2c5afbd1c6 100644 --- a/src/apps/dashboard/routes/activity/index.tsx +++ b/src/apps/dashboard/routes/activity/index.tsx @@ -11,7 +11,7 @@ import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntrie import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell'; import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell'; import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell'; -import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; +import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton'; import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index e8ad8bf074..4d9df29793 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -14,7 +14,7 @@ import { type MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-rea import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import { useApi } from 'hooks/useApi'; import { getDeviceIcon } from 'utils/image'; -import UserAvatarButton from 'apps/dashboard/features/activity/components/UserAvatarButton'; +import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice'; import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice'; From 671ab3751ae64a7c8fcc108cac09b3cc3c49b981 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 13:09:33 -0500 Subject: [PATCH 06/14] Refactor devices page components --- .../devices/components/DeviceNameCell.tsx | 22 ++++++++++ .../features/devices/types/deviceInfoCell.ts | 7 +++ src/apps/dashboard/routes/devices/index.tsx | 44 +++++-------------- 3 files changed, 41 insertions(+), 32 deletions(-) create mode 100644 src/apps/dashboard/features/devices/components/DeviceNameCell.tsx create mode 100644 src/apps/dashboard/features/devices/types/deviceInfoCell.ts diff --git a/src/apps/dashboard/features/devices/components/DeviceNameCell.tsx b/src/apps/dashboard/features/devices/components/DeviceNameCell.tsx new file mode 100644 index 0000000000..efcf8301f4 --- /dev/null +++ b/src/apps/dashboard/features/devices/components/DeviceNameCell.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; + +import { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell'; +import { getDeviceIcon } from 'utils/image'; + +const DeviceNameCell: FC = ({ row, renderedCellValue }) => ( + <> + {row.original.AppName + {renderedCellValue} + +); + +export default DeviceNameCell; diff --git a/src/apps/dashboard/features/devices/types/deviceInfoCell.ts b/src/apps/dashboard/features/devices/types/deviceInfoCell.ts new file mode 100644 index 0000000000..e9b1af2ad9 --- /dev/null +++ b/src/apps/dashboard/features/devices/types/deviceInfoCell.ts @@ -0,0 +1,7 @@ +import type { DeviceInfoDto } from '@jellyfin/sdk/lib/generated-client/models/device-info-dto'; +import type { MRT_Row } from 'material-react-table'; + +export interface DeviceInfoCell { + renderedCellValue: React.ReactNode + row: MRT_Row +} diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 4d9df29793..35d4dafb74 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -5,41 +5,21 @@ import Box from '@mui/material/Box/Box'; import Button from '@mui/material/Button/Button'; import IconButton from '@mui/material/IconButton/IconButton'; import Tooltip from '@mui/material/Tooltip/Tooltip'; -import React, { FC, useCallback, useMemo, useState } from 'react'; +import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; +import React, { useCallback, useMemo, useState } from 'react'; import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage'; -import { useDevices } from 'apps/dashboard/features/devices/api/useDevices'; -import globalize from 'lib/globalize'; -import { type MRT_ColumnDef, MRT_Row, useMaterialReactTable } from 'material-react-table'; -import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; -import { useApi } from 'hooks/useApi'; -import { getDeviceIcon } from 'utils/image'; import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton'; -import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; -import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice'; import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice'; +import { useDevices } from 'apps/dashboard/features/devices/api/useDevices'; +import { useUpdateDevice } from 'apps/dashboard/features/devices/api/useUpdateDevice'; +import DeviceNameCell from 'apps/dashboard/features/devices/components/DeviceNameCell'; +import type { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell'; import ConfirmDialog from 'components/ConfirmDialog'; - -interface DeviceInfoCell { - renderedCellValue: React.ReactNode - row: MRT_Row -} - -const DeviceNameCell: FC = ({ row, renderedCellValue }) => ( - <> - {row.original.AppName - {renderedCellValue} - -); +import { useApi } from 'hooks/useApi'; +import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; +import globalize from 'lib/globalize'; +import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) { return ( @@ -53,7 +33,7 @@ const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellVal ); }; -const DevicesPage = () => { +export const Component = () => { const { api } = useApi(); const { data: devices, isLoading: isDevicesLoading } = useDevices({}); const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails(); @@ -267,4 +247,4 @@ const DevicesPage = () => { ); }; -export default DevicesPage; +Component.displayName = 'DevicesPage'; From 674dc7aa61b4db3dcb6758ad314abb5f5eaad14b Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 13:16:14 -0500 Subject: [PATCH 07/14] Remove legacy devices page --- .../drawer/sections/DevicesDrawerSection.tsx | 8 - .../dashboard/controllers/devices/device.html | 23 --- .../dashboard/controllers/devices/device.js | 54 ------ .../controllers/devices/devices.html | 21 --- .../dashboard/controllers/devices/devices.js | 171 ------------------ src/apps/dashboard/routes/_asyncRoutes.ts | 2 +- src/apps/dashboard/routes/_legacyRoutes.ts | 14 -- 7 files changed, 1 insertion(+), 292 deletions(-) delete mode 100644 src/apps/dashboard/controllers/devices/device.html delete mode 100644 src/apps/dashboard/controllers/devices/device.js delete mode 100644 src/apps/dashboard/controllers/devices/devices.html delete mode 100644 src/apps/dashboard/controllers/devices/devices.js diff --git a/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx index b8ea68e708..18fcb010e8 100644 --- a/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/DevicesDrawerSection.tsx @@ -27,14 +27,6 @@ const DevicesDrawerSection = () => { - - - - - - - - diff --git a/src/apps/dashboard/controllers/devices/device.html b/src/apps/dashboard/controllers/devices/device.html deleted file mode 100644 index 45dd733f72..0000000000 --- a/src/apps/dashboard/controllers/devices/device.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
-
-
-
-
-

-
- -
- -
${LabelCustomDeviceDisplayNameHelp}
-
-
-
- -
-
-
-
-
diff --git a/src/apps/dashboard/controllers/devices/device.js b/src/apps/dashboard/controllers/devices/device.js deleted file mode 100644 index 3b84b133e7..0000000000 --- a/src/apps/dashboard/controllers/devices/device.js +++ /dev/null @@ -1,54 +0,0 @@ -import loading from 'components/loading/loading'; -import dom from 'scripts/dom'; -import 'elements/emby-input/emby-input'; -import 'elements/emby-button/emby-button'; -import Dashboard from 'utils/dashboard'; -import { getParameterByName } from 'utils/url.ts'; - -function load(page, device, deviceOptions) { - page.querySelector('#txtCustomName', page).value = deviceOptions?.CustomName || ''; - page.querySelector('.reportedName', page).innerText = device.Name || ''; -} - -function loadData() { - const page = this; - loading.show(); - const id = getParameterByName('id'); - const device = ApiClient.getJSON(ApiClient.getUrl('Devices/Info', { - Id: id - })); - const deviceOptions = ApiClient.getJSON(ApiClient.getUrl('Devices/Options', { - Id: id - })).catch(() => undefined); - Promise.all([device, deviceOptions]).then(function (responses) { - load(page, responses[0], responses[1]); - loading.hide(); - }); -} - -function save(page) { - const id = getParameterByName('id'); - ApiClient.ajax({ - url: ApiClient.getUrl('Devices/Options', { - Id: id - }), - type: 'POST', - data: JSON.stringify({ - CustomName: page.querySelector('#txtCustomName').value - }), - contentType: 'application/json' - }).then(Dashboard.processServerConfigurationUpdateResult); -} - -function onSubmit(e) { - const form = this; - save(dom.parentWithClass(form, 'page')); - e.preventDefault(); - return false; -} - -export default function (view) { - view.querySelector('form').addEventListener('submit', onSubmit); - view.addEventListener('viewshow', loadData); -} - diff --git a/src/apps/dashboard/controllers/devices/devices.html b/src/apps/dashboard/controllers/devices/devices.html deleted file mode 100644 index 3d8825a339..0000000000 --- a/src/apps/dashboard/controllers/devices/devices.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
-
-
-
-

${HeaderDevices}

- -
-
-
-
-
-
diff --git a/src/apps/dashboard/controllers/devices/devices.js b/src/apps/dashboard/controllers/devices/devices.js deleted file mode 100644 index 01dd1f1732..0000000000 --- a/src/apps/dashboard/controllers/devices/devices.js +++ /dev/null @@ -1,171 +0,0 @@ -import { formatDistanceToNow } from 'date-fns'; -import escapeHtml from 'escape-html'; - -import loading from 'components/loading/loading'; -import dom from 'scripts/dom'; -import globalize from 'lib/globalize'; -import imageHelper from 'utils/image'; -import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts'; -import 'elements/emby-button/emby-button'; -import 'elements/emby-itemscontainer/emby-itemscontainer'; -import 'components/cardbuilder/card.scss'; -import Dashboard from 'utils/dashboard'; -import confirm from 'components/confirm/confirm'; -import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils'; - -// Local cache of loaded -let deviceIds = []; - -function canDelete(deviceId) { - return deviceId !== ApiClient.deviceId(); -} - -function deleteAllDevices(page) { - const msg = globalize.translate('DeleteDevicesConfirmation'); - - confirm({ - text: msg, - title: globalize.translate('HeaderDeleteDevices'), - confirmText: globalize.translate('Delete'), - primary: 'delete' - }).then(async () => { - loading.show(); - await Promise.all( - deviceIds.filter(canDelete).map((id) => ApiClient.deleteDevice(id)) - ); - loadData(page); - }); -} - -function deleteDevice(page, id) { - const msg = globalize.translate('DeleteDeviceConfirmation'); - - confirm({ - text: msg, - title: globalize.translate('HeaderDeleteDevice'), - confirmText: globalize.translate('Delete'), - primary: 'delete' - }).then(async () => { - loading.show(); - await ApiClient.deleteDevice(id); - loadData(page); - }); -} - -function showDeviceMenu(view, btn, deviceId) { - const menuItems = [{ - name: globalize.translate('Edit'), - id: 'open', - icon: 'mode_edit' - }]; - - if (canDelete(deviceId)) { - menuItems.push({ - name: globalize.translate('Delete'), - id: 'delete', - icon: 'delete' - }); - } - - import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => { - actionsheet.show({ - items: menuItems, - positionTo: btn, - callback: function (id) { - switch (id) { - case 'open': - Dashboard.navigate('dashboard/devices/edit?id=' + deviceId); - break; - - case 'delete': - deleteDevice(view, deviceId); - } - } - }); - }); -} - -function load(page, devices) { - const localeWithSuffix = getLocaleWithSuffix(); - - let html = ''; - html += devices.map(function (device) { - let deviceHtml = ''; - deviceHtml += "
"; - deviceHtml += '
'; - deviceHtml += ''; - deviceHtml += '
'; - - if (canDelete(device.Id)) { - if (globalize.getIsRTL()) { - deviceHtml += '
'; - } else { - deviceHtml += '
'; - } - deviceHtml += ''; - deviceHtml += '
'; - } - - deviceHtml += "
"; - deviceHtml += escapeHtml(device.CustomName || device.Name); - deviceHtml += '
'; - deviceHtml += "
"; - deviceHtml += escapeHtml(device.AppName + ' ' + device.AppVersion); - deviceHtml += '
'; - deviceHtml += "
"; - - if (device.LastUserName) { - deviceHtml += escapeHtml(device.LastUserName); - deviceHtml += ', ' + formatDistanceToNow(Date.parse(device.DateLastActivity), localeWithSuffix); - } - - deviceHtml += ' '; - deviceHtml += '
'; - deviceHtml += '
'; - deviceHtml += '
'; - deviceHtml += '
'; - return deviceHtml; - }).join(''); - page.querySelector('.devicesList').innerHTML = html; -} - -function loadData(page) { - loading.show(); - ApiClient.getJSON(ApiClient.getUrl('Devices')).then(function (result) { - load(page, result.Items); - deviceIds = result.Items.map((device) => device.Id); - loading.hide(); - }); -} - -export default function (view) { - view.querySelector('.devicesList').addEventListener('click', function (e) { - const btnDeviceMenu = dom.parentWithClass(e.target, 'btnDeviceMenu'); - - if (btnDeviceMenu) { - showDeviceMenu(view, btnDeviceMenu, btnDeviceMenu.getAttribute('data-id')); - } - }); - view.addEventListener('viewshow', function () { - loadData(this); - }); - - view.querySelector('#deviceDeleteAll').addEventListener('click', function() { - deleteAllDevices(view); - }); -} - diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 0cda4e2595..8c65b38060 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -4,7 +4,7 @@ import { AppType } from 'constants/appType'; export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'activity', type: AppType.Dashboard }, { path: 'branding', type: AppType.Dashboard }, - { path: 'devices2', page: 'devices', type: AppType.Dashboard }, + { path: 'devices', type: AppType.Dashboard }, { path: 'keys', type: AppType.Dashboard }, { path: 'logs', type: AppType.Dashboard }, { path: 'playback/trickplay', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index c2d9359ee2..a20083e3d2 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -23,20 +23,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'networking', view: 'networking.html' } - }, { - path: 'devices', - pageProps: { - appType: AppType.Dashboard, - controller: 'devices/devices', - view: 'devices/devices.html' - } - }, { - path: 'devices/edit', - pageProps: { - appType: AppType.Dashboard, - controller: 'devices/device', - view: 'devices/device.html' - } }, { path: 'libraries', pageProps: { From fd22b8461a7e3c02a200b02e7991eb444d616f63 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 13:25:22 -0500 Subject: [PATCH 08/14] Remove actions column label --- src/apps/dashboard/routes/devices/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 35d4dafb74..1ff02377c0 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -172,6 +172,11 @@ export const Component = () => { // Custom actions enableRowActions: true, positionActionsColumn: 'last', + displayColumnDefOptions: { + 'mrt-row-actions': { + header: '' + } + }, renderRowActions: ({ row, table }) => { const isDeletable = api && row.original.Id && api.deviceInfo.id === row.original.Id; return ( From 38ffbd06e3dc9f6c6efe2bf2a1a9dab857211c14 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 13:28:33 -0500 Subject: [PATCH 09/14] Add error logging for deleting all devices --- src/apps/dashboard/routes/devices/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 1ff02377c0..7600e51b46 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -85,6 +85,9 @@ export const Component = () => { } return Promise.resolve(); })) + .catch(err => { + console.error('[DevicesPage] failed deleting all devices', err); + }) .finally(() => { onCloseDeleteAllConfirmDialog(); }); From bd328b92025301d3ad92d2f73a43657257aa8501 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 17:36:56 -0500 Subject: [PATCH 10/14] Fix date column label and formatting --- src/apps/dashboard/routes/devices/index.tsx | 11 +++++++---- src/strings/en-us.json | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 7600e51b46..c0ec1e96b9 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -5,6 +5,8 @@ import Box from '@mui/material/Box/Box'; import Button from '@mui/material/Button/Button'; import IconButton from '@mui/material/IconButton/IconButton'; import Tooltip from '@mui/material/Tooltip/Tooltip'; +import format from 'date-fns/format'; +import parseISO from 'date-fns/parseISO'; import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import React, { useCallback, useMemo, useState } from 'react'; @@ -17,9 +19,9 @@ import DeviceNameCell from 'apps/dashboard/features/devices/components/DeviceNam import type { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell'; import ConfirmDialog from 'components/ConfirmDialog'; import { useApi } from 'hooks/useApi'; +import { useLocale } from 'hooks/useLocale'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; import globalize from 'lib/globalize'; -import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) { return ( @@ -36,6 +38,7 @@ const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellVal export const Component = () => { const { api } = useApi(); const { data: devices, isLoading: isDevicesLoading } = useDevices({}); + const { dateFnsLocale } = useLocale(); const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails(); const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false); @@ -99,10 +102,10 @@ export const Component = () => { const columns = useMemo[]>(() => [ { id: 'DateLastActivity', - accessorFn: row => parseISO8601Date(row.DateLastActivity), - header: globalize.translate('LabelTime'), + accessorFn: row => row.DateLastActivity ? parseISO(row.DateLastActivity) : undefined, + header: globalize.translate('LastActive'), size: 160, - Cell: ({ cell }) => toLocaleString(cell.getValue()), + Cell: ({ cell }) => format(cell.getValue(), 'Ppp', { locale: dateFnsLocale }), filterVariant: 'datetime-range', enableEditing: false }, diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 8770151ff2..bff1f768cc 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1007,6 +1007,7 @@ "LanNetworksHelp": "Comma separated list of IP addresses or IP/netmask entries for networks that will be considered on local network when enforcing bandwidth restrictions. If set, all other IP addresses will be considered to be on the external network and will be subject to the external bandwidth restrictions. If left blank, only the server's subnet is considered to be on the local network.", "Large": "Large", "Larger": "Larger", + "LastActive": "Last active", "LastSeen": "Last activity {0}", "LatestFromLibrary": "Recently Added in {0}", "LearnHowYouCanContribute": "Learn how you can contribute.", From 6d1da8fcbaa71541793d8d0a42e0cf84440ae0b2 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 30 Jan 2025 17:40:05 -0500 Subject: [PATCH 11/14] Fix missing hook dependency --- src/apps/dashboard/routes/devices/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index c0ec1e96b9..920398ecc2 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -134,7 +134,7 @@ export const Component = () => { filterVariant: 'multi-select', filterSelectOptions: userNames } - ], [ UserCell, userNames ]); + ], [ UserCell, dateFnsLocale, userNames ]); const mrTable = useMaterialReactTable({ ...DEFAULT_TABLE_OPTIONS, From bd92527529aeb45c504f30cd5df2700b5925cac5 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 31 Jan 2025 12:22:55 -0500 Subject: [PATCH 12/14] Update time format --- src/apps/dashboard/routes/devices/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 920398ecc2..52f3287e80 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -105,7 +105,7 @@ export const Component = () => { accessorFn: row => row.DateLastActivity ? parseISO(row.DateLastActivity) : undefined, header: globalize.translate('LastActive'), size: 160, - Cell: ({ cell }) => format(cell.getValue(), 'Ppp', { locale: dateFnsLocale }), + Cell: ({ cell }) => format(cell.getValue(), 'Pp', { locale: dateFnsLocale }), filterVariant: 'datetime-range', enableEditing: false }, From 5262c9bee63a6e14b71db23d55f0e9ea9233c762 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 31 Jan 2025 16:20:08 -0500 Subject: [PATCH 13/14] Add common DateTimeCell for tables --- .../dashboard/components/table/DateTimeCell.tsx | 17 +++++++++++++++++ .../components/{ => table}/TablePage.tsx | 0 src/apps/dashboard/routes/activity/index.tsx | 9 +++++---- src/apps/dashboard/routes/devices/index.tsx | 10 ++++------ src/apps/dashboard/routes/keys/index.tsx | 14 +++++++++----- 5 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 src/apps/dashboard/components/table/DateTimeCell.tsx rename src/apps/dashboard/components/{ => table}/TablePage.tsx (100%) diff --git a/src/apps/dashboard/components/table/DateTimeCell.tsx b/src/apps/dashboard/components/table/DateTimeCell.tsx new file mode 100644 index 0000000000..a684e4b796 --- /dev/null +++ b/src/apps/dashboard/components/table/DateTimeCell.tsx @@ -0,0 +1,17 @@ +import format from 'date-fns/format'; +import type { MRT_Cell, MRT_RowData } from 'material-react-table'; +import { FC } from 'react'; + +import { useLocale } from 'hooks/useLocale'; + +interface CellProps { + cell: MRT_Cell +} + +const DateTimeCell: FC = ({ cell }) => { + const { dateFnsLocale } = useLocale(); + + return format(cell.getValue(), 'Pp', { locale: dateFnsLocale }); +}; + +export default DateTimeCell; diff --git a/src/apps/dashboard/components/TablePage.tsx b/src/apps/dashboard/components/table/TablePage.tsx similarity index 100% rename from src/apps/dashboard/components/TablePage.tsx rename to src/apps/dashboard/components/table/TablePage.tsx diff --git a/src/apps/dashboard/routes/activity/index.tsx b/src/apps/dashboard/routes/activity/index.tsx index 2c5afbd1c6..3e46c9e5e8 100644 --- a/src/apps/dashboard/routes/activity/index.tsx +++ b/src/apps/dashboard/routes/activity/index.tsx @@ -1,3 +1,4 @@ +import parseISO from 'date-fns/parseISO'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; @@ -6,7 +7,8 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import { useSearchParams } from 'react-router-dom'; -import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage'; +import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell'; +import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage'; import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell'; import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell'; @@ -14,7 +16,6 @@ import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCe import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton'; import type { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; -import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; import globalize from 'lib/globalize'; import { toBoolean } from 'utils/string'; @@ -82,10 +83,10 @@ const Activity = () => { const columns = useMemo[]>(() => [ { id: 'Date', - accessorFn: row => parseISO8601Date(row.Date), + accessorFn: row => row.Date ? parseISO(row.Date) : undefined, header: globalize.translate('LabelTime'), size: 160, - Cell: ({ cell }) => toLocaleString(cell.getValue()), + Cell: DateTimeCell, filterVariant: 'datetime-range' }, { diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 52f3287e80..724374845e 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -5,12 +5,12 @@ import Box from '@mui/material/Box/Box'; import Button from '@mui/material/Button/Button'; import IconButton from '@mui/material/IconButton/IconButton'; import Tooltip from '@mui/material/Tooltip/Tooltip'; -import format from 'date-fns/format'; import parseISO from 'date-fns/parseISO'; import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; import React, { useCallback, useMemo, useState } from 'react'; -import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/TablePage'; +import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell'; +import TablePage, { DEFAULT_TABLE_OPTIONS } from 'apps/dashboard/components/table/TablePage'; import UserAvatarButton from 'apps/dashboard/components/UserAvatarButton'; import { useDeleteDevice } from 'apps/dashboard/features/devices/api/useDeleteDevice'; import { useDevices } from 'apps/dashboard/features/devices/api/useDevices'; @@ -19,7 +19,6 @@ import DeviceNameCell from 'apps/dashboard/features/devices/components/DeviceNam import type { DeviceInfoCell } from 'apps/dashboard/features/devices/types/deviceInfoCell'; import ConfirmDialog from 'components/ConfirmDialog'; import { useApi } from 'hooks/useApi'; -import { useLocale } from 'hooks/useLocale'; import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; import globalize from 'lib/globalize'; @@ -38,7 +37,6 @@ const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellVal export const Component = () => { const { api } = useApi(); const { data: devices, isLoading: isDevicesLoading } = useDevices({}); - const { dateFnsLocale } = useLocale(); const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails(); const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false); @@ -105,7 +103,7 @@ export const Component = () => { accessorFn: row => row.DateLastActivity ? parseISO(row.DateLastActivity) : undefined, header: globalize.translate('LastActive'), size: 160, - Cell: ({ cell }) => format(cell.getValue(), 'Pp', { locale: dateFnsLocale }), + Cell: DateTimeCell, filterVariant: 'datetime-range', enableEditing: false }, @@ -134,7 +132,7 @@ export const Component = () => { filterVariant: 'multi-select', filterSelectOptions: userNames } - ], [ UserCell, dateFnsLocale, userNames ]); + ], [ UserCell, userNames ]); const mrTable = useMaterialReactTable({ ...DEFAULT_TABLE_OPTIONS, diff --git a/src/apps/dashboard/routes/keys/index.tsx b/src/apps/dashboard/routes/keys/index.tsx index daedaa74b4..54c59d0da9 100644 --- a/src/apps/dashboard/routes/keys/index.tsx +++ b/src/apps/dashboard/routes/keys/index.tsx @@ -1,3 +1,6 @@ +import parseISO from 'date-fns/parseISO'; + +import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell'; import Page from 'components/Page'; import { useApi } from 'hooks/useApi'; import globalize from 'lib/globalize'; @@ -14,7 +17,6 @@ import Stack from '@mui/material/Stack'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; -import { getDisplayTime, parseISO8601Date, toLocaleDateString } from 'scripts/datetime'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; @@ -38,8 +40,8 @@ const ApiKeys = () => { }, { id: 'DateIssued', - accessorFn: item => parseISO8601Date(item.DateCreated), - Cell: ({ cell }) => toLocaleDateString(cell.getValue()) + ' ' + getDisplayTime(cell.getValue()), + accessorFn: item => item.DateCreated ? parseISO(item.DateCreated) : undefined, + Cell: DateTimeCell, header: globalize.translate('HeaderDateIssued'), filterVariant: 'datetime-range' } @@ -77,8 +79,10 @@ const ApiKeys = () => { }, renderTopToolbarCustomActions: () => ( - ), From fd0c3ab20430bc5ebbe336205e721af38da44d3f Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 13 Feb 2025 10:33:24 -0500 Subject: [PATCH 14/14] Fix page index reset on refetch --- src/apps/dashboard/routes/devices/index.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx index 724374845e..c554971834 100644 --- a/src/apps/dashboard/routes/devices/index.tsx +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -36,7 +36,10 @@ const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellVal export const Component = () => { const { api } = useApi(); - const { data: devices, isLoading: isDevicesLoading } = useDevices({}); + const { data, isLoading: isDevicesLoading, isRefetching } = useDevices({}); + const devices = useMemo(() => ( + data?.Items || [] + ), [ data ]); const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails(); const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState(false); @@ -78,9 +81,9 @@ export const Component = () => { }, []); const onConfirmDeleteAll = useCallback(() => { - if (devices?.Items) { + if (devices) { Promise - .all(devices.Items.map(item => { + .all(devices.map(item => { if (api && item.Id && api.deviceInfo.id === item.Id) { return deleteDevice.mutateAsync({ id: item.Id }); } @@ -93,7 +96,7 @@ export const Component = () => { onCloseDeleteAllConfirmDialog(); }); } - }, [ api, deleteDevice, devices?.Items, onCloseDeleteAllConfirmDialog ]); + }, [ api, deleteDevice, devices, onCloseDeleteAllConfirmDialog ]); const UserCell = getUserCell(users); @@ -138,7 +141,7 @@ export const Component = () => { ...DEFAULT_TABLE_OPTIONS, columns, - data: devices?.Items || [], + data: devices, // State initialState: { @@ -152,6 +155,9 @@ export const Component = () => { isLoading }, + // Do not reset the page index when refetching data + autoResetPageIndex: !isRefetching, + // Editing device name enableEditing: true, onEditingRowSave: ({ table, row, values }) => {