diff --git a/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx b/src/apps/dashboard/components/UserAvatarButton.tsx similarity index 73% rename from src/apps/dashboard/features/activity/components/UserAvatarButton.tsx rename to src/apps/dashboard/components/UserAvatarButton.tsx index 91f126e92cf..e8f4530de37 100644 --- a/src/apps/dashboard/features/activity/components/UserAvatarButton.tsx +++ b/src/apps/dashboard/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 DateTimeCell: FC = ({ cell }) => { + const { dateFnsLocale } = useLocale(); + + return format(cell.getValue(), 'Pp', { locale: dateFnsLocale }); +}; + +export default DateTimeCell; diff --git a/src/apps/dashboard/components/table/TablePage.tsx b/src/apps/dashboard/components/table/TablePage.tsx new file mode 100644 index 00000000000..4e5daef2a5e --- /dev/null +++ b/src/apps/dashboard/components/table/TablePage.tsx @@ -0,0 +1,63 @@ +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 +} + +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 ( + + + + + {title} + + + + + {children} + + ); +}; + +export default TablePage; diff --git a/src/apps/dashboard/features/devices/api/useDeleteDevice.ts b/src/apps/dashboard/features/devices/api/useDeleteDevice.ts new file mode 100644 index 00000000000..a3fbd658cc8 --- /dev/null +++ b/src/apps/dashboard/features/devices/api/useDeleteDevice.ts @@ -0,0 +1,24 @@ +import type { DevicesApiDeleteDeviceRequest } 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 useDeleteDevice = () => { + 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 new file mode 100644 index 00000000000..782383078fc --- /dev/null +++ b/src/apps/dashboard/features/devices/api/useDevices.ts @@ -0,0 +1,38 @@ +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'; + +export const QUERY_KEY = 'Devices'; + +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: [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 00000000000..740c5ca67b0 --- /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/features/devices/components/DeviceNameCell.tsx b/src/apps/dashboard/features/devices/components/DeviceNameCell.tsx new file mode 100644 index 00000000000..efcf8301f44 --- /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 00000000000..e9b1af2ad97 --- /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/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index eb42010cf3a..8c65b380609 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: '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 56e19ccc110..a5462c82c4d 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: 'dashboard/networking', view: 'dashboard/networking.html' } - }, { - path: 'devices', - pageProps: { - appType: AppType.Dashboard, - controller: 'dashboard/devices/devices', - view: 'dashboard/devices/devices.html' - } - }, { - path: 'devices/edit', - pageProps: { - appType: AppType.Dashboard, - controller: 'dashboard/devices/device', - view: 'dashboard/devices/device.html' - } }, { path: 'libraries', pageProps: { diff --git a/src/apps/dashboard/routes/activity/index.tsx b/src/apps/dashboard/routes/activity/index.tsx index 5b0e328777c..3e46c9e5e8a 100644 --- a/src/apps/dashboard/routes/activity/index.tsx +++ b/src/apps/dashboard/routes/activity/index.tsx @@ -1,28 +1,24 @@ +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'; -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 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'; 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 Page from 'components/Page'; -import { useUsers } from 'hooks/useUsers'; -import { parseISO8601Date, toLocaleString } from 'scripts/datetime'; +import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; import globalize from 'lib/globalize'; import { toBoolean } from 'utils/string'; -type UsersRecords = Record; - const DEFAULT_PAGE_SIZE = 25; const VIEW_PARAM = 'useractivity'; @@ -55,29 +51,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); @@ -109,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' }, { @@ -177,22 +151,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' @@ -229,31 +192,12 @@ const Activity = () => { }); return ( - - - - - {globalize.translate('HeaderActivity')} - - - - - + table={table} + /> ); }; diff --git a/src/apps/dashboard/routes/devices/index.tsx b/src/apps/dashboard/routes/devices/index.tsx new file mode 100644 index 00000000000..724374845ee --- /dev/null +++ b/src/apps/dashboard/routes/devices/index.tsx @@ -0,0 +1,259 @@ +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 parseISO from 'date-fns/parseISO'; +import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table'; +import React, { useCallback, useMemo, useState } from 'react'; + +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'; +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'; +import { useApi } from 'hooks/useApi'; +import { type UsersRecords, useUsersDetails } from 'hooks/useUsers'; +import globalize from 'lib/globalize'; + +const getUserCell = (users: UsersRecords) => function UserCell({ renderedCellValue, row }: DeviceInfoCell) { + return ( + <> + + {renderedCellValue} + + ); +}; + +export const Component = () => { + const { api } = useApi(); + const { data: devices, isLoading: isDevicesLoading } = useDevices({}); + 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 onDeleteDevice = useCallback((id: string | null | undefined) => () => { + if (id) { + setPendingDeleteDeviceId(id); + setIsDeleteConfirmOpen(true); + } + }, []); + + const onCloseDeleteConfirmDialog = useCallback(() => { + setPendingDeleteDeviceId(undefined); + setIsDeleteConfirmOpen(false); + }, []); + + 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(); + })) + .catch(err => { + console.error('[DevicesPage] failed deleting all devices', err); + }) + .finally(() => { + onCloseDeleteAllConfirmDialog(); + }); + } + }, [ api, deleteDevice, devices?.Items, onCloseDeleteAllConfirmDialog ]); + + const UserCell = getUserCell(users); + + const columns = useMemo[]>(() => [ + { + id: 'DateLastActivity', + accessorFn: row => row.DateLastActivity ? parseISO(row.DateLastActivity) : undefined, + header: globalize.translate('LastActive'), + size: 160, + Cell: DateTimeCell, + filterVariant: 'datetime-range', + enableEditing: false + }, + { + id: 'Name', + accessorFn: row => row.CustomName || row.Name, + header: globalize.translate('LabelDevice'), + size: 200, + Cell: DeviceNameCell + }, + { + 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: UserCell, + filterVariant: 'multi-select', + filterSelectOptions: userNames + } + ], [ UserCell, userNames ]); + + const mrTable = useMaterialReactTable({ + ...DEFAULT_TABLE_OPTIONS, + + columns, + data: devices?.Items || [], + + // State + initialState: { + density: 'compact', + pagination: { + pageIndex: 0, + pageSize: 25 + } + }, + state: { + 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', + displayColumnDefOptions: { + 'mrt-row-actions': { + header: '' + } + }, + 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: () => ( + + ) + }); + + return ( + + + + + ); +}; + +Component.displayName = 'DevicesPage'; diff --git a/src/apps/dashboard/routes/keys/index.tsx b/src/apps/dashboard/routes/keys/index.tsx index daedaa74b4c..54c59d0da98 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: () => ( - ), diff --git a/src/components/Page.tsx b/src/components/Page.tsx index 6787e8f5459..f3f9db7249e 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 = '', diff --git a/src/controllers/dashboard/devices/device.html b/src/controllers/dashboard/devices/device.html deleted file mode 100644 index 45dd733f726..00000000000 --- a/src/controllers/dashboard/devices/device.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
-
-
-
-
-

-
- -
- -
${LabelCustomDeviceDisplayNameHelp}
-
-
-
- -
-
-
-
-
diff --git a/src/controllers/dashboard/devices/device.js b/src/controllers/dashboard/devices/device.js deleted file mode 100644 index 120b478163c..00000000000 --- a/src/controllers/dashboard/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/controllers/dashboard/devices/devices.html b/src/controllers/dashboard/devices/devices.html deleted file mode 100644 index 3d8825a3395..00000000000 --- a/src/controllers/dashboard/devices/devices.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
-
-
-
-

${HeaderDevices}

- -
-
-
-
-
-
diff --git a/src/controllers/dashboard/devices/devices.js b/src/controllers/dashboard/devices/devices.js deleted file mode 100644 index ab118847090..00000000000 --- a/src/controllers/dashboard/devices/devices.js +++ /dev/null @@ -1,170 +0,0 @@ -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 { formatDistanceToNow } from 'date-fns'; -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/hooks/useUsers.ts b/src/hooks/useUsers.ts index cc62d6b2c36..3e9d1f3b6f1 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 + }; +}; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 9c69f90285e..bff1f768ccc 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", @@ -1006,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.",