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",