1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #6489 from thornbill/device-page-table

Rewrite devices dashboard page in react
This commit is contained in:
Bill Thornton 2025-02-13 17:28:01 -05:00 committed by GitHub
commit bd8176833e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 524 additions and 363 deletions

View file

@ -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<Theme>
}
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => (
const UserAvatarButton: FC<UserAvatarButtonProps> = ({
user,
sx
}) => (
user?.Id ? (
<IconButton
size='large'
color='inherit'
sx={{ padding: 0 }}
sx={{
padding: 0,
...sx
}}
title={user.Name || undefined}
component={Link}
to={`/dashboard/users/profile?userId=${user.Id}`}

View file

@ -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<MRT_RowData>
}
const DateTimeCell: FC<CellProps> = ({ cell }) => {
const { dateFnsLocale } = useLocale();
return format(cell.getValue<Date>(), 'Pp', { locale: dateFnsLocale });
};
export default DateTimeCell;

View file

@ -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<T extends MRT_RowData> extends PageProps {
title: string
table: MRT_TableInstance<T>
}
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 = <T extends MRT_RowData>({
title,
table,
children,
...pageProps
}: TablePageProps<T>) => {
return (
<Page
title={title}
{...pageProps}
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Box
sx={{
marginBottom: 1
}}
>
<Typography variant='h2'>
{title}
</Typography>
</Box>
<MaterialReactTable table={table} />
</Box>
{children}
</Page>
);
};
export default TablePage;

View file

@ -1,23 +0,0 @@
<div id="devicePage" data-role="page" class="page type-interior devicesPage noSecondaryNavPage">
<div>
<div class="content-primary">
<form class="deviceForm">
<div class="verticalSection verticalSection-extrabottompadding">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle reportedName"></h2>
</div>
<div class="inputContainer">
<input is="emby-input" type="text" id="txtCustomName" label="${LabelDisplayName}" />
<div class="fieldDescription">${LabelCustomDeviceDisplayNameHelp}</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>${Save}</span>
</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -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);
}

View file

@ -1,21 +0,0 @@
<div id="devicesPage" data-role="page" class="page type-interior devicesPage noSecondaryNavPage" data-title="${HeaderDevices}">
<div>
<div class="content-primary">
<div class="verticalSection verticalSection">
<div class="sectionTitleContainer sectionTitleContainer-cards flex align-items-center">
<h2 class="sectionTitle sectionTitle-cards">${HeaderDevices}</h2>
<button
id="deviceDeleteAll"
is="emby-button"
type="button"
class="raised button-alt"
style="margin-left: 1.25em !important; padding-bottom: 0.4em !important; padding-top: 0.4em !important;"
>
${DeleteAll}
</button>
</div>
</div>
<div is="emby-itemscontainer" class="devicesList vertical-wrap" data-multiselect="false"></div>
</div>
</div>
</div>

View file

@ -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 += "<div data-id='" + escapeHtml(device.Id) + "' class='card backdropCard'>";
deviceHtml += '<div class="cardBox visualCardBox">';
deviceHtml += '<div class="cardScalable">';
deviceHtml += '<div class="cardPadder cardPadder-backdrop"></div>';
deviceHtml += `<a is="emby-linkbutton" href="#/dashboard/devices/edit?id=${escapeHtml(device.Id)}" class="cardContent cardImageContainer ${getDefaultBackgroundClass()}">`;
// audit note: getDeviceIcon returns static text
const iconUrl = imageHelper.getDeviceIcon(device);
if (iconUrl) {
deviceHtml += '<div class="cardImage" style="background-image:url(\'' + iconUrl + "');background-size:contain;background-position:center center;background-origin:content-box;padding:1em;\">";
deviceHtml += '</div>';
} else {
deviceHtml += '<span class="cardImageIcon material-icons tablet_android" aria-hidden="true"></span>';
}
deviceHtml += '</a>';
deviceHtml += '</div>';
deviceHtml += '<div class="cardFooter">';
if (canDelete(device.Id)) {
if (globalize.getIsRTL()) {
deviceHtml += '<div style="text-align:left; float:left;padding-top:5px;">';
} else {
deviceHtml += '<div style="text-align:right; float:right;padding-top:5px;">';
}
deviceHtml += '<button type="button" is="paper-icon-button-light" data-id="' + escapeHtml(device.Id) + '" title="' + globalize.translate('Menu') + '" class="btnDeviceMenu"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
deviceHtml += '</div>';
}
deviceHtml += "<div class='cardText'>";
deviceHtml += escapeHtml(device.CustomName || device.Name);
deviceHtml += '</div>';
deviceHtml += "<div class='cardText cardText-secondary'>";
deviceHtml += escapeHtml(device.AppName + ' ' + device.AppVersion);
deviceHtml += '</div>';
deviceHtml += "<div class='cardText cardText-secondary'>";
if (device.LastUserName) {
deviceHtml += escapeHtml(device.LastUserName);
deviceHtml += ', ' + formatDistanceToNow(Date.parse(device.DateLastActivity), localeWithSuffix);
}
deviceHtml += '&nbsp;';
deviceHtml += '</div>';
deviceHtml += '</div>';
deviceHtml += '</div>';
deviceHtml += '</div>';
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);
});
}

View file

@ -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 ]
});
}
});
};

View file

@ -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
});
};

View file

@ -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 ]
});
}
});
};

View file

@ -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<DeviceInfoCell> = ({ row, renderedCellValue }) => (
<>
<img
alt={row.original.AppName || undefined}
src={getDeviceIcon(row.original)}
style={{
display: 'inline-block',
maxWidth: '1.5em',
maxHeight: '1.5em',
marginRight: '1rem'
}}
/>
{renderedCellValue}
</>
);
export default DeviceNameCell;

View file

@ -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<DeviceInfoDto>
}

View file

@ -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 },

View file

@ -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: {

View file

@ -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<string, UserDto>;
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<UsersRecords>((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<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
{
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<Date>()),
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 (
<Page
<TablePage
id='serverActivityPage'
title={globalize.translate('HeaderActivity')}
className='mainAnimatedPage type-interior'
>
<Box
className='content-primary'
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<Box
sx={{
marginBottom: 1
}}
>
<Typography variant='h2'>
{globalize.translate('HeaderActivity')}
</Typography>
</Box>
<MaterialReactTable table={table} />
</Box>
</Page>
table={table}
/>
);
};

View file

@ -0,0 +1,265 @@
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 (
<>
<UserAvatarButton
user={row.original.LastUserId && users[row.original.LastUserId] || undefined}
sx={{ mr: '1rem' }}
/>
{renderedCellValue}
</>
);
};
export const Component = () => {
const { api } = useApi();
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);
const [ isDeleteAllConfirmOpen, setIsDeleteAllConfirmOpen ] = useState(false);
const [ pendingDeleteDeviceId, setPendingDeleteDeviceId ] = useState<string>();
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) {
Promise
.all(devices.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, onCloseDeleteAllConfirmDialog ]);
const UserCell = getUserCell(users);
const columns = useMemo<MRT_ColumnDef<DeviceInfoDto>[]>(() => [
{
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,
// State
initialState: {
density: 'compact',
pagination: {
pageIndex: 0,
pageSize: 25
}
},
state: {
isLoading
},
// Do not reset the page index when refetching data
autoResetPageIndex: !isRefetching,
// 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 (
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={globalize.translate('Edit')}>
<IconButton
// eslint-disable-next-line react/jsx-no-bind
onClick={() => table.setEditingRow(row)}
>
<Edit />
</IconButton>
</Tooltip>
{/* Don't include Tooltip when disabled */}
{isDeletable ? (
<IconButton
color='error'
disabled
>
<Delete />
</IconButton>
) : (
<Tooltip title={globalize.translate('Delete')}>
<IconButton
color='error'
onClick={onDeleteDevice(row.original.Id)}
>
<Delete />
</IconButton>
</Tooltip>
)}
</Box>
);
},
// Custom toolbar contents
renderTopToolbarCustomActions: () => (
<Button
color='error'
startIcon={<Delete />}
onClick={onDeleteAll}
>
{globalize.translate('DeleteAll')}
</Button>
)
});
return (
<TablePage
id='devicesPage'
title={globalize.translate('HeaderDevices')}
className='mainAnimatedPage type-interior'
table={mrTable}
>
<ConfirmDialog
open={isDeleteConfirmOpen}
title={globalize.translate('HeaderDeleteDevice')}
text={globalize.translate('DeleteDeviceConfirmation')}
onCancel={onCloseDeleteConfirmDialog}
onConfirm={onConfirmDelete}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
<ConfirmDialog
open={isDeleteAllConfirmOpen}
title={globalize.translate('HeaderDeleteDevices')}
text={globalize.translate('DeleteDevicesConfirmation')}
onCancel={onCloseDeleteAllConfirmDialog}
onConfirm={onConfirmDeleteAll}
confirmButtonColor='error'
confirmButtonText={globalize.translate('Delete')}
/>
</TablePage>
);
};
Component.displayName = 'DevicesPage';

View file

@ -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<Date>()) + ' ' + getDisplayTime(cell.getValue<Date>()),
accessorFn: item => item.DateCreated ? parseISO(item.DateCreated) : undefined,
Cell: DateTimeCell,
header: globalize.translate('HeaderDateIssued'),
filterVariant: 'datetime-range'
}
@ -77,8 +79,10 @@ const ApiKeys = () => {
},
renderTopToolbarCustomActions: () => (
<Button onClick={showNewKeyPopup}>
<AddIcon />
<Button
startIcon={<AddIcon />}
onClick={showNewKeyPopup}
>
{globalize.translate('HeaderNewApiKey')}
</Button>
),

View file

@ -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<HTMLDivElement>;
/**
* 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<PropsWithChildren<PageProps & HTMLAttributes<HTMLDivElement>>> = ({
const Page: FC<PropsWithChildren<PageProps>> = ({
children,
id,
className = '',

View file

@ -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<string, UserDto>;
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
};
};

View file

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