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:
commit
bd8176833e
20 changed files with 524 additions and 363 deletions
|
@ -1,4 +1,5 @@
|
||||||
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
|
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 IconButton from '@mui/material/IconButton/IconButton';
|
||||||
import React, { type FC } from 'react';
|
import React, { type FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
@ -7,14 +8,21 @@ import UserAvatar from 'components/UserAvatar';
|
||||||
|
|
||||||
interface UserAvatarButtonProps {
|
interface UserAvatarButtonProps {
|
||||||
user?: UserDto
|
user?: UserDto
|
||||||
|
sx?: SxProps<Theme>
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserAvatarButton: FC<UserAvatarButtonProps> = ({ user }) => (
|
const UserAvatarButton: FC<UserAvatarButtonProps> = ({
|
||||||
|
user,
|
||||||
|
sx
|
||||||
|
}) => (
|
||||||
user?.Id ? (
|
user?.Id ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
size='large'
|
size='large'
|
||||||
color='inherit'
|
color='inherit'
|
||||||
sx={{ padding: 0 }}
|
sx={{
|
||||||
|
padding: 0,
|
||||||
|
...sx
|
||||||
|
}}
|
||||||
title={user.Name || undefined}
|
title={user.Name || undefined}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/dashboard/users/profile?userId=${user.Id}`}
|
to={`/dashboard/users/profile?userId=${user.Id}`}
|
17
src/apps/dashboard/components/table/DateTimeCell.tsx
Normal file
17
src/apps/dashboard/components/table/DateTimeCell.tsx
Normal 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;
|
63
src/apps/dashboard/components/table/TablePage.tsx
Normal file
63
src/apps/dashboard/components/table/TablePage.tsx
Normal 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;
|
|
@ -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>
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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 += ' ';
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
24
src/apps/dashboard/features/devices/api/useDeleteDevice.ts
Normal file
24
src/apps/dashboard/features/devices/api/useDeleteDevice.ts
Normal 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 ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
38
src/apps/dashboard/features/devices/api/useDevices.ts
Normal file
38
src/apps/dashboard/features/devices/api/useDevices.ts
Normal 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
|
||||||
|
});
|
||||||
|
};
|
24
src/apps/dashboard/features/devices/api/useUpdateDevice.ts
Normal file
24
src/apps/dashboard/features/devices/api/useUpdateDevice.ts
Normal 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 ]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -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;
|
|
@ -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>
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { AppType } from 'constants/appType';
|
||||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||||
{ path: 'activity', type: AppType.Dashboard },
|
{ path: 'activity', type: AppType.Dashboard },
|
||||||
{ path: 'branding', type: AppType.Dashboard },
|
{ path: 'branding', type: AppType.Dashboard },
|
||||||
|
{ path: 'devices', type: AppType.Dashboard },
|
||||||
{ path: 'keys', type: AppType.Dashboard },
|
{ path: 'keys', type: AppType.Dashboard },
|
||||||
{ path: 'logs', type: AppType.Dashboard },
|
{ path: 'logs', type: AppType.Dashboard },
|
||||||
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
{ path: 'playback/trickplay', type: AppType.Dashboard },
|
||||||
|
|
|
@ -23,20 +23,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||||
controller: 'networking',
|
controller: 'networking',
|
||||||
view: 'networking.html'
|
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',
|
path: 'libraries',
|
||||||
pageProps: {
|
pageProps: {
|
||||||
|
|
|
@ -1,28 +1,24 @@
|
||||||
|
import parseISO from 'date-fns/parseISO';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
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 { 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 ToggleButton from '@mui/material/ToggleButton';
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import Typography from '@mui/material/Typography';
|
import { type MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||||
import { type MRT_ColumnDef, MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
|
||||||
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
|
import ActionsCell from 'apps/dashboard/features/activity/components/ActionsCell';
|
||||||
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
|
import LogLevelCell from 'apps/dashboard/features/activity/components/LogLevelCell';
|
||||||
import OverviewCell from 'apps/dashboard/features/activity/components/OverviewCell';
|
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 { ActivityLogEntryCell } from 'apps/dashboard/features/activity/types/ActivityLogEntryCell';
|
||||||
import Page from 'components/Page';
|
import { type UsersRecords, useUsersDetails } from 'hooks/useUsers';
|
||||||
import { useUsers } from 'hooks/useUsers';
|
|
||||||
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
|
||||||
import globalize from 'lib/globalize';
|
import globalize from 'lib/globalize';
|
||||||
import { toBoolean } from 'utils/string';
|
import { toBoolean } from 'utils/string';
|
||||||
|
|
||||||
type UsersRecords = Record<string, UserDto>;
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const VIEW_PARAM = 'useractivity';
|
const VIEW_PARAM = 'useractivity';
|
||||||
|
|
||||||
|
@ -55,29 +51,7 @@ const Activity = () => {
|
||||||
pageSize: DEFAULT_PAGE_SIZE
|
pageSize: DEFAULT_PAGE_SIZE
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: usersData, isLoading: isUsersLoading } = useUsers();
|
const { usersById: users, names: userNames, isLoading: isUsersLoading } = useUsersDetails();
|
||||||
|
|
||||||
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 UserCell = getUserCell(users);
|
const UserCell = getUserCell(users);
|
||||||
|
|
||||||
|
@ -109,10 +83,10 @@ const Activity = () => {
|
||||||
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
|
const columns = useMemo<MRT_ColumnDef<ActivityLogEntry>[]>(() => [
|
||||||
{
|
{
|
||||||
id: 'Date',
|
id: 'Date',
|
||||||
accessorFn: row => parseISO8601Date(row.Date),
|
accessorFn: row => row.Date ? parseISO(row.Date) : undefined,
|
||||||
header: globalize.translate('LabelTime'),
|
header: globalize.translate('LabelTime'),
|
||||||
size: 160,
|
size: 160,
|
||||||
Cell: ({ cell }) => toLocaleString(cell.getValue<Date>()),
|
Cell: DateTimeCell,
|
||||||
filterVariant: 'datetime-range'
|
filterVariant: 'datetime-range'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -177,22 +151,11 @@ const Activity = () => {
|
||||||
}, [ activityView, searchParams, setSearchParams ]);
|
}, [ activityView, searchParams, setSearchParams ]);
|
||||||
|
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
|
...DEFAULT_TABLE_OPTIONS,
|
||||||
|
|
||||||
columns,
|
columns,
|
||||||
data: logEntries?.Items || [],
|
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
|
// State
|
||||||
initialState: {
|
initialState: {
|
||||||
density: 'compact'
|
density: 'compact'
|
||||||
|
@ -229,31 +192,12 @@ const Activity = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<TablePage
|
||||||
id='serverActivityPage'
|
id='serverActivityPage'
|
||||||
title={globalize.translate('HeaderActivity')}
|
title={globalize.translate('HeaderActivity')}
|
||||||
className='mainAnimatedPage type-interior'
|
className='mainAnimatedPage type-interior'
|
||||||
>
|
table={table}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
265
src/apps/dashboard/routes/devices/index.tsx
Normal file
265
src/apps/dashboard/routes/devices/index.tsx
Normal 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';
|
|
@ -1,3 +1,6 @@
|
||||||
|
import parseISO from 'date-fns/parseISO';
|
||||||
|
|
||||||
|
import DateTimeCell from 'apps/dashboard/components/table/DateTimeCell';
|
||||||
import Page from 'components/Page';
|
import Page from 'components/Page';
|
||||||
import { useApi } from 'hooks/useApi';
|
import { useApi } from 'hooks/useApi';
|
||||||
import globalize from 'lib/globalize';
|
import globalize from 'lib/globalize';
|
||||||
|
@ -14,7 +17,6 @@ import Stack from '@mui/material/Stack';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
import { MaterialReactTable, MRT_ColumnDef, useMaterialReactTable } from 'material-react-table';
|
||||||
import { getDisplayTime, parseISO8601Date, toLocaleDateString } from 'scripts/datetime';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
|
||||||
|
@ -38,8 +40,8 @@ const ApiKeys = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'DateIssued',
|
id: 'DateIssued',
|
||||||
accessorFn: item => parseISO8601Date(item.DateCreated),
|
accessorFn: item => item.DateCreated ? parseISO(item.DateCreated) : undefined,
|
||||||
Cell: ({ cell }) => toLocaleDateString(cell.getValue<Date>()) + ' ' + getDisplayTime(cell.getValue<Date>()),
|
Cell: DateTimeCell,
|
||||||
header: globalize.translate('HeaderDateIssued'),
|
header: globalize.translate('HeaderDateIssued'),
|
||||||
filterVariant: 'datetime-range'
|
filterVariant: 'datetime-range'
|
||||||
}
|
}
|
||||||
|
@ -77,8 +79,10 @@ const ApiKeys = () => {
|
||||||
},
|
},
|
||||||
|
|
||||||
renderTopToolbarCustomActions: () => (
|
renderTopToolbarCustomActions: () => (
|
||||||
<Button onClick={showNewKeyPopup}>
|
<Button
|
||||||
<AddIcon />
|
startIcon={<AddIcon />}
|
||||||
|
onClick={showNewKeyPopup}
|
||||||
|
>
|
||||||
{globalize.translate('HeaderNewApiKey')}
|
{globalize.translate('HeaderNewApiKey')}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React, { type FC, type PropsWithChildren, type HTMLAttributes, useEffect,
|
||||||
|
|
||||||
import viewManager from './viewManager/viewManager';
|
import viewManager from './viewManager/viewManager';
|
||||||
|
|
||||||
type PageProps = {
|
type CustomPageProps = {
|
||||||
id: string, // id is required for libraryMenu
|
id: string, // id is required for libraryMenu
|
||||||
title?: string,
|
title?: string,
|
||||||
isBackButtonEnabled?: boolean,
|
isBackButtonEnabled?: boolean,
|
||||||
|
@ -12,11 +12,13 @@ type PageProps = {
|
||||||
backDropType?: string,
|
backDropType?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PageProps = CustomPageProps & HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page component that handles hiding active non-react views, triggering the required events for
|
* 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.
|
* 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,
|
children,
|
||||||
id,
|
id,
|
||||||
className = '',
|
className = '',
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import type { AxiosRequestConfig } from 'axios';
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
import type { Api } from '@jellyfin/sdk';
|
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 { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useApi } from './useApi';
|
import { useApi } from './useApi';
|
||||||
|
|
||||||
|
export type UsersRecords = Record<string, UserDto>;
|
||||||
|
|
||||||
const fetchUsers = async (
|
const fetchUsers = async (
|
||||||
api?: Api,
|
api?: Api,
|
||||||
requestParams?: UserApiGetUsersRequest,
|
requestParams?: UserApiGetUsersRequest,
|
||||||
|
@ -32,3 +34,24 @@ export const useUsers = (requestParams?: UserApiGetUsersRequest) => {
|
||||||
enabled: !!api
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -661,6 +661,7 @@
|
||||||
"LabelDelimiterWhitelist": "Delimiter Whitelist",
|
"LabelDelimiterWhitelist": "Delimiter Whitelist",
|
||||||
"LabelDelimiterWhitelistHelp": "Items to be excluded from tag splitting. One item per line.",
|
"LabelDelimiterWhitelistHelp": "Items to be excluded from tag splitting. One item per line.",
|
||||||
"LabelDeveloper": "Developer",
|
"LabelDeveloper": "Developer",
|
||||||
|
"LabelDevice": "Device",
|
||||||
"LabelDisableCustomCss": "Disable custom CSS code for theming/branding provided from the server.",
|
"LabelDisableCustomCss": "Disable custom CSS code for theming/branding provided from the server.",
|
||||||
"LabelDisableVbrAudioEncoding": "Disable VBR audio encoding",
|
"LabelDisableVbrAudioEncoding": "Disable VBR audio encoding",
|
||||||
"LabelDiscNumber": "Disc number",
|
"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.",
|
"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",
|
"Large": "Large",
|
||||||
"Larger": "Larger",
|
"Larger": "Larger",
|
||||||
|
"LastActive": "Last active",
|
||||||
"LastSeen": "Last activity {0}",
|
"LastSeen": "Last activity {0}",
|
||||||
"LatestFromLibrary": "Recently Added in {0}",
|
"LatestFromLibrary": "Recently Added in {0}",
|
||||||
"LearnHowYouCanContribute": "Learn how you can contribute.",
|
"LearnHowYouCanContribute": "Learn how you can contribute.",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue