mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge pull request #5773 from thornbill/add-plugin-redesign
This commit is contained in:
commit
03f4251afb
28 changed files with 1092 additions and 241 deletions
|
@ -17,18 +17,14 @@ import { useLocation } from 'react-router-dom';
|
|||
import ListItemLink from 'components/ListItemLink';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
const PLUGIN_PATHS = [
|
||||
'/dashboard/plugins',
|
||||
'/dashboard/plugins/catalog',
|
||||
'/dashboard/plugins/repositories',
|
||||
'/dashboard/plugins/add',
|
||||
'/configurationpage'
|
||||
];
|
||||
const isPluginPath = (path: string) => (
|
||||
path.startsWith('/dashboard/plugins')
|
||||
|| path === '/configurationpage'
|
||||
);
|
||||
|
||||
const AdvancedDrawerSection = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
|
||||
const isPluginSectionOpen = isPluginPath(location.pathname);
|
||||
|
||||
return (
|
||||
<List
|
||||
|
|
21
src/apps/dashboard/features/plugins/api/configurationPage.ts
Normal file
21
src/apps/dashboard/features/plugins/api/configurationPage.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
|
||||
|
||||
export const findBestConfigurationPage = (
|
||||
configurationPages: ConfigurationPageInfo[],
|
||||
pluginId: string
|
||||
) => {
|
||||
// Find candidates matching the plugin id
|
||||
const candidates = configurationPages.filter(c => c.PluginId === pluginId);
|
||||
|
||||
// If none are found, return undefined
|
||||
if (candidates.length === 0) return;
|
||||
// If only one is found, return it
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Prefer the first candidate with the EnableInMainMenu flag for consistency
|
||||
const menuCandidate = candidates.find(c => !!c.EnableInMainMenu);
|
||||
if (menuCandidate) return menuCandidate;
|
||||
|
||||
// Fallback to the first match
|
||||
return candidates[0];
|
||||
};
|
25
src/apps/dashboard/features/plugins/api/pluginInfo.ts
Normal file
25
src/apps/dashboard/features/plugins/api/pluginInfo.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
|
||||
/**
|
||||
* HACK: The Plugins API is returning garbage data in some cases,
|
||||
* so we need to try to find the "best" match if multiple exist.
|
||||
*/
|
||||
export const findBestPluginInfo = (
|
||||
pluginId: string,
|
||||
plugins?: PluginInfo[]
|
||||
) => {
|
||||
if (!plugins) return;
|
||||
// Find all plugin entries with a matching ID
|
||||
const matches = plugins.filter(p => p.Id === pluginId);
|
||||
// Get the first match (or undefined if none)
|
||||
const firstMatch = matches?.[0];
|
||||
|
||||
if (matches.length > 1) {
|
||||
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|
||||
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|
||||
|| firstMatch; // Fallback to the first match
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
};
|
5
src/apps/dashboard/features/plugins/api/queryKey.ts
Normal file
5
src/apps/dashboard/features/plugins/api/queryKey.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export enum QueryKey {
|
||||
ConfigurationPages = 'ConfigurationPages',
|
||||
PackageInfo = 'PackageInfo',
|
||||
Plugins = 'Plugins'
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
|
||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchConfigurationPages = async (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchConfigurationPages] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getDashboardApi(api)
|
||||
.getConfigurationPages(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getConfigurationPagesQuery = (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
|
||||
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useConfigurationPages = (
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getConfigurationPagesQuery(api, params));
|
||||
};
|
24
src/apps/dashboard/features/plugins/api/useDisablePlugin.ts
Normal file
24
src/apps/dashboard/features/plugins/api/useDisablePlugin.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useDisablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiDisablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.disablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
24
src/apps/dashboard/features/plugins/api/useEnablePlugin.ts
Normal file
24
src/apps/dashboard/features/plugins/api/useEnablePlugin.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useEnablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiEnablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.enablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
27
src/apps/dashboard/features/plugins/api/useInstallPackage.ts
Normal file
27
src/apps/dashboard/features/plugins/api/useInstallPackage.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useInstallPackage = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PackageApiInstallPackageRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPackageApi(api!)
|
||||
.installPackage(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
47
src/apps/dashboard/features/plugins/api/usePackageInfo.ts
Normal file
47
src/apps/dashboard/features/plugins/api/usePackageInfo.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPackageInfo] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
console.warn('[fetchPackageInfo] Missing request params');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackageInfoQuery = (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
|
||||
enabled: !!api && !!params?.name
|
||||
});
|
||||
|
||||
export const usePackageInfo = (
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPackageInfoQuery(api, params));
|
||||
};
|
36
src/apps/dashboard/features/plugins/api/usePlugins.ts
Normal file
36
src/apps/dashboard/features/plugins/api/usePlugins.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPlugins = async (
|
||||
api?: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPlugins] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getPluginsApi(api)
|
||||
.getPlugins(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPluginsQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Plugins ],
|
||||
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const usePlugins = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPluginsQuery(api));
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useUninstallPlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.uninstallPluginByVersion(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,94 @@
|
|||
import Link from '@mui/material/Link/Link';
|
||||
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Table from '@mui/material/Table/Table';
|
||||
import TableBody from '@mui/material/TableBody/TableBody';
|
||||
import TableCell from '@mui/material/TableCell/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer/TableContainer';
|
||||
import TableRow from '@mui/material/TableRow/TableRow';
|
||||
import React, { FC } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
|
||||
interface PluginDetailsTableProps extends PaperProps {
|
||||
isPluginLoading: boolean
|
||||
isRepositoryLoading: boolean
|
||||
pluginDetails?: PluginDetails
|
||||
}
|
||||
|
||||
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||
isPluginLoading,
|
||||
isRepositoryLoading,
|
||||
pluginDetails,
|
||||
...paperProps
|
||||
}) => (
|
||||
<TableContainer component={Paper} {...paperProps}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelStatus')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.status
|
||||
|| globalize.translate('LabelNotInstalled')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelVersion')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.version?.version
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelDeveloper')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| pluginDetails?.owner
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelRepository')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| (pluginDetails?.version?.repositoryUrl && (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={pluginDetails.version.repositoryUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{pluginDetails.version.repositoryName}
|
||||
</Link>
|
||||
))
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
export default PluginDetailsTable;
|
|
@ -0,0 +1,34 @@
|
|||
import Paper from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
interface PluginImageProps {
|
||||
isLoading: boolean
|
||||
alt?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const PluginImage: FC<PluginImageProps> = ({
|
||||
isLoading,
|
||||
alt,
|
||||
url
|
||||
}) => (
|
||||
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
variant='rectangular'
|
||||
width='100%'
|
||||
height='100%'
|
||||
/>
|
||||
)}
|
||||
{url && (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
width='100%'
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default PluginImage;
|
|
@ -0,0 +1,67 @@
|
|||
import Download from '@mui/icons-material/Download';
|
||||
import DownloadDone from '@mui/icons-material/DownloadDone';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import Accordion from '@mui/material/Accordion/Accordion';
|
||||
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
|
||||
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import MarkdownBox from 'components/MarkdownBox';
|
||||
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
interface PluginRevisionsProps {
|
||||
pluginDetails?: PluginDetails,
|
||||
onInstall: (version?: VersionInfo) => () => void
|
||||
}
|
||||
|
||||
const PluginRevisions: FC<PluginRevisionsProps> = ({
|
||||
pluginDetails,
|
||||
onInstall
|
||||
}) => (
|
||||
pluginDetails?.versions?.map(version => (
|
||||
<Accordion key={version.checksum}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
>
|
||||
{version.version}
|
||||
{version.timestamp && (<>
|
||||
—
|
||||
{toLocaleString(parseISO8601Date(version.timestamp))}
|
||||
</>)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<MarkdownBox
|
||||
fallback={globalize.translate('LabelNoChangelog')}
|
||||
markdown={version.changelog}
|
||||
/>
|
||||
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
|
||||
<Button
|
||||
disabled
|
||||
startIcon={<DownloadDone />}
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('LabelInstalled')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
variant='outlined'
|
||||
onClick={onInstall(version)}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
);
|
||||
|
||||
export default PluginRevisions;
|
15
src/apps/dashboard/features/plugins/types/PluginDetails.ts
Normal file
15
src/apps/dashboard/features/plugins/types/PluginDetails.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
description?: string
|
||||
id: string
|
||||
imageUrl?: string
|
||||
isEnabled: boolean
|
||||
name?: string
|
||||
owner?: string
|
||||
configurationPage?: ConfigurationPageInfo
|
||||
status?: PluginStatus
|
||||
version?: VersionInfo
|
||||
versions: VersionInfo[]
|
||||
}
|
|
@ -2,11 +2,12 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
|
|||
|
||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: 'activity', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/access', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/add', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/password', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard },
|
||||
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
|
||||
{ path: 'users/profile', type: AsyncRouteType.Dashboard }
|
||||
];
|
||||
|
|
|
@ -31,12 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
|||
controller: 'dashboard/devices/device',
|
||||
view: 'dashboard/devices/device.html'
|
||||
}
|
||||
}, {
|
||||
path: 'plugins/add',
|
||||
pageProps: {
|
||||
controller: 'dashboard/plugins/add/index',
|
||||
view: 'dashboard/plugins/add/index.html'
|
||||
}
|
||||
}, {
|
||||
path: 'libraries',
|
||||
pageProps: {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import type { Redirect } from 'components/router/Redirect';
|
||||
|
||||
export const REDIRECTS: Redirect[] = [
|
||||
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
|
||||
{ from: 'apikeys.html', to: '/dashboard/keys' },
|
||||
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
|
||||
{ from: 'dashboard.html', to: '/dashboard' },
|
||||
|
|
443
src/apps/dashboard/routes/plugins/plugin.tsx
Normal file
443
src/apps/dashboard/routes/plugins/plugin.tsx
Normal file
|
@ -0,0 +1,443 @@
|
|||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
|
||||
import Alert from '@mui/material/Alert/Alert';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Container from '@mui/material/Container/Container';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
|
||||
import FormGroup from '@mui/material/FormGroup/FormGroup';
|
||||
import Grid from '@mui/material/Grid/Grid';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import Switch from '@mui/material/Switch/Switch';
|
||||
import Typography from '@mui/material/Typography/Typography';
|
||||
import Delete from '@mui/icons-material/Delete';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
import Settings from '@mui/icons-material/Settings';
|
||||
import React, { type FC, useState, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
|
||||
|
||||
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
|
||||
import { findBestPluginInfo } from 'apps/dashboard/features/plugins/api/pluginInfo';
|
||||
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
|
||||
import { useDisablePlugin } from 'apps/dashboard/features/plugins/api/useDisablePlugin';
|
||||
import { useEnablePlugin } from 'apps/dashboard/features/plugins/api/useEnablePlugin';
|
||||
import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstallPackage';
|
||||
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
|
||||
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
|
||||
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
|
||||
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
|
||||
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
|
||||
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
|
||||
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
|
||||
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import Page from 'components/Page';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import globalize from 'scripts/globalize';
|
||||
import { getPluginUrl } from 'utils/dashboard';
|
||||
import { getUri } from 'utils/api';
|
||||
|
||||
interface AlertMessage {
|
||||
severity?: 'success' | 'info' | 'warning' | 'error'
|
||||
messageKey: string
|
||||
}
|
||||
|
||||
// Plugins from this url will be trusted and not prompt for confirmation when installing
|
||||
const TRUSTED_REPO_URL = 'https://repo.jellyfin.org/';
|
||||
|
||||
const PluginPage: FC = () => {
|
||||
const { api } = useApi();
|
||||
const { pluginId } = useParams();
|
||||
const [ searchParams ] = useSearchParams();
|
||||
const disablePlugin = useDisablePlugin();
|
||||
const enablePlugin = useEnablePlugin();
|
||||
const installPlugin = useInstallPackage();
|
||||
const uninstallPlugin = useUninstallPlugin();
|
||||
|
||||
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
|
||||
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
|
||||
|
||||
const pluginName = searchParams.get('name') ?? undefined;
|
||||
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isLoading: isConfigurationPagesLoading
|
||||
} = useConfigurationPages();
|
||||
|
||||
const {
|
||||
data: packageInfo,
|
||||
isError: isPackageInfoError,
|
||||
isLoading: isPackageInfoLoading
|
||||
} = usePackageInfo(pluginName ? {
|
||||
name: pluginName,
|
||||
assemblyGuid: pluginId
|
||||
} : undefined);
|
||||
|
||||
const {
|
||||
data: plugins,
|
||||
isLoading: isPluginsLoading,
|
||||
isError: isPluginsError
|
||||
} = usePlugins();
|
||||
|
||||
const isLoading =
|
||||
isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading;
|
||||
|
||||
const pluginDetails = useMemo<PluginDetails | undefined>(() => {
|
||||
if (pluginId && !isPluginsLoading) {
|
||||
const pluginInfo = findBestPluginInfo(pluginId, plugins);
|
||||
|
||||
let version;
|
||||
if (pluginInfo) {
|
||||
// Find the installed version
|
||||
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
|
||||
version = repoVersion || {
|
||||
version: pluginInfo.Version,
|
||||
VersionNumber: pluginInfo.Version
|
||||
};
|
||||
} else {
|
||||
// Use the latest version
|
||||
version = packageInfo?.versions?.[0];
|
||||
}
|
||||
|
||||
let imageUrl;
|
||||
if (pluginInfo?.HasImage) {
|
||||
imageUrl = getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`, api);
|
||||
}
|
||||
|
||||
return {
|
||||
canUninstall: !!pluginInfo?.CanUninstall,
|
||||
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
|
||||
id: pluginId,
|
||||
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
|
||||
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
|
||||
?? pluginInfo?.Status !== PluginStatus.Disabled,
|
||||
name: pluginName || pluginInfo?.Name || packageInfo?.name,
|
||||
owner: packageInfo?.owner,
|
||||
status: pluginInfo?.Status,
|
||||
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
|
||||
version,
|
||||
versions: packageInfo?.versions || []
|
||||
};
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
configurationPages,
|
||||
isEnabledOverride,
|
||||
isPluginsLoading,
|
||||
packageInfo?.description,
|
||||
packageInfo?.imageUrl,
|
||||
packageInfo?.name,
|
||||
packageInfo?.overview,
|
||||
packageInfo?.owner,
|
||||
packageInfo?.versions,
|
||||
pluginId,
|
||||
pluginName,
|
||||
plugins
|
||||
]);
|
||||
|
||||
const alertMessages = useMemo(() => {
|
||||
const alerts: AlertMessage[] = [];
|
||||
|
||||
if (disablePlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginDisableError' });
|
||||
}
|
||||
|
||||
if (enablePlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginEnableError' });
|
||||
}
|
||||
|
||||
if (installPlugin.isSuccess) {
|
||||
alerts.push({
|
||||
severity: 'success',
|
||||
messageKey: 'MessagePluginInstalled'
|
||||
});
|
||||
}
|
||||
|
||||
if (installPlugin.isError) {
|
||||
alerts.push({ messageKey: 'MessagePluginInstallError' });
|
||||
}
|
||||
|
||||
if (uninstallPlugin.isError) {
|
||||
alerts.push({ messageKey: 'PluginUninstallError' });
|
||||
}
|
||||
|
||||
if (isConfigurationPagesError) {
|
||||
alerts.push({ messageKey: 'PluginLoadConfigError' });
|
||||
}
|
||||
|
||||
if (isPackageInfoError) {
|
||||
alerts.push({
|
||||
severity: 'warning',
|
||||
messageKey: 'PluginLoadRepoError'
|
||||
});
|
||||
}
|
||||
|
||||
if (isPluginsError) {
|
||||
alerts.push({ messageKey: 'MessageGetInstalledPluginsError' });
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}, [
|
||||
disablePlugin.isError,
|
||||
enablePlugin.isError,
|
||||
installPlugin.isError,
|
||||
installPlugin.isSuccess,
|
||||
isConfigurationPagesError,
|
||||
isPackageInfoError,
|
||||
isPluginsError,
|
||||
uninstallPlugin.isError
|
||||
]);
|
||||
|
||||
/** Enable/disable the plugin */
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!pluginDetails?.version?.version) return;
|
||||
|
||||
console.debug('[PluginPage] %s plugin', pluginDetails.isEnabled ? 'disabling' : 'enabling', pluginDetails);
|
||||
|
||||
if (pluginDetails.isEnabled) {
|
||||
disablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
installPlugin.reset();
|
||||
enablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
enablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
installPlugin.reset();
|
||||
disablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Install the plugin or prompt for confirmation if untrusted */
|
||||
const onInstall = useCallback((version?: VersionInfo, isConfirmed = false) => () => {
|
||||
if (!pluginDetails?.name) return;
|
||||
const installVersion = version || pluginDetails.version;
|
||||
if (!installVersion) return;
|
||||
|
||||
if (!isConfirmed && !installVersion.repositoryUrl?.startsWith(TRUSTED_REPO_URL)) {
|
||||
console.debug('[PluginPage] plugin install needs confirmed', installVersion);
|
||||
setPendingInstallVersion(installVersion);
|
||||
setIsInstallConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('[PluginPage] installing plugin', installVersion);
|
||||
|
||||
installPlugin.mutate({
|
||||
name: pluginDetails.name,
|
||||
assemblyGuid: pluginDetails.id,
|
||||
version: installVersion.version,
|
||||
repositoryUrl: installVersion.repositoryUrl
|
||||
}, {
|
||||
onSettled: () => {
|
||||
setPendingInstallVersion(undefined);
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
uninstallPlugin.reset();
|
||||
}
|
||||
});
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Confirm and install the plugin */
|
||||
const onConfirmInstall = useCallback(() => {
|
||||
console.debug('[PluginPage] confirmed installing plugin', pendingInstallVersion);
|
||||
setIsInstallConfirmOpen(false);
|
||||
onInstall(pendingInstallVersion, true)();
|
||||
}, [ onInstall, pendingInstallVersion ]);
|
||||
|
||||
/** Close the install confirmation dialog */
|
||||
const onCloseInstallConfirmDialog = useCallback(() => {
|
||||
setPendingInstallVersion(undefined);
|
||||
setIsInstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
/** Show the uninstall confirmation dialog */
|
||||
const onConfirmUninstall = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
/** Uninstall the plugin */
|
||||
const onUninstall = useCallback(() => {
|
||||
if (!pluginDetails?.version?.version) return;
|
||||
|
||||
console.debug('[PluginPage] uninstalling plugin', pluginDetails);
|
||||
|
||||
setIsUninstallConfirmOpen(false);
|
||||
|
||||
uninstallPlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSettled: () => {
|
||||
disablePlugin.reset();
|
||||
enablePlugin.reset();
|
||||
installPlugin.reset();
|
||||
}
|
||||
});
|
||||
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Close the uninstall confirmation dialog */
|
||||
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='addPluginPage'
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Container className='content-primary'>
|
||||
|
||||
{alertMessages.map(({ severity = 'error', messageKey }) => (
|
||||
<Alert key={messageKey} severity={severity}>
|
||||
{globalize.translate(messageKey)}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Stack spacing={2}>
|
||||
<Typography variant='h1'>
|
||||
{pluginDetails?.name || pluginName}
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ maxWidth: '80ch' }}>
|
||||
{isLoading && !pluginDetails?.description ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
pluginDetails?.description
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
|
||||
<PluginImage
|
||||
isLoading={isLoading}
|
||||
alt={pluginDetails?.name}
|
||||
url={pluginDetails?.imageUrl}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
|
||||
{!!pluginDetails?.versions.length && (
|
||||
<>
|
||||
<Typography variant='h3' sx={{ marginBottom: 2 }}>
|
||||
{globalize.translate('HeaderRevisionHistory')}
|
||||
</Typography>
|
||||
<PluginRevisions
|
||||
pluginDetails={pluginDetails}
|
||||
onInstall={onInstall}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
|
||||
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
|
||||
{!isLoading && !pluginDetails?.status && (
|
||||
<>
|
||||
<Alert severity='info'>
|
||||
{globalize.translate('ServerRestartNeededAfterPluginInstall')}
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
onClick={onInstall()}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.canUninstall && (
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={pluginDetails.isEnabled}
|
||||
onChange={toggleEnabled}
|
||||
disabled={pluginDetails.status === PluginStatus.Restart}
|
||||
/>
|
||||
}
|
||||
label={globalize.translate('LabelEnablePlugin')}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.configurationPage?.Name && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/${getPluginUrl(pluginDetails.configurationPage.Name)}`}
|
||||
startIcon={<Settings />}
|
||||
>
|
||||
{globalize.translate('Settings')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isLoading && pluginDetails?.canUninstall && (
|
||||
<Button
|
||||
color='error'
|
||||
startIcon={<Delete />}
|
||||
onClick={onConfirmUninstall}
|
||||
>
|
||||
{globalize.translate('ButtonUninstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<PluginDetailsTable
|
||||
isPluginLoading={isPluginsLoading}
|
||||
isRepositoryLoading={isPackageInfoLoading}
|
||||
pluginDetails={pluginDetails}
|
||||
sx={{ flexBasis: '50%' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isInstallConfirmOpen}
|
||||
title={globalize.translate('HeaderConfirmPluginInstallation')}
|
||||
text={globalize.translate('MessagePluginInstallDisclaimer')}
|
||||
onCancel={onCloseInstallConfirmDialog}
|
||||
onConfirm={onConfirmInstall}
|
||||
confirmButtonText={globalize.translate('HeaderInstall')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isUninstallConfirmOpen}
|
||||
title={globalize.translate('HeaderUninstallPlugin')}
|
||||
text={globalize.translate('UninstallPluginConfirmation', pluginName || '')}
|
||||
onCancel={onCloseUninstallConfirmDialog}
|
||||
onConfirm={onUninstall}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginPage;
|
Loading…
Add table
Add a link
Reference in a new issue