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 + }; +};