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 000000000..206173099 --- /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 5b0e32877..2b424c9e5 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 6787e8f54..f3f9db724 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 18fcb010e..b8ea68e70 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 000000000..8335f9087 --- /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 eb42010cf..0cda4e259 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 000000000..b8b1b91aa --- /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 9c69f9028..8770151ff 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 2b424c9e5..a9734c774 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 206173099..4e5daef2a 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 91f126e92..e8f4530de 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 8335f9087..782383078 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 000000000..740c5ca67 --- /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 a9734c774..2793ff4fe 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 b8b1b91aa..e8ad8bf07 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 cc62d6b2c..3e9d1f3b6 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 2793ff4fe..2c5afbd1c 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 e8ad8bf07..4d9df2979 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 000000000..efcf8301f --- /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 000000000..e9b1af2ad --- /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 4d9df2979..35d4dafb7 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 b8ea68e70..18fcb010e 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 45dd733f7..000000000 --- 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 3b84b133e..000000000 --- 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 3d8825a33..000000000 --- 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 01dd1f173..000000000 --- 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 0cda4e259..8c65b3806 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 c2d9359ee..a20083e3d 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 35d4dafb7..1ff02377c 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 1ff02377c..7600e51b4 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 7600e51b4..c0ec1e96b 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 8770151ff..bff1f768c 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 c0ec1e96b..920398ecc 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 920398ecc..52f3287e8 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 000000000..a684e4b79 --- /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 2c5afbd1c..3e46c9e5e 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 52f3287e8..724374845 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 daedaa74b..54c59d0da 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 724374845..c55497183 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 }) => {