mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Redesign add plugins page
This commit is contained in:
parent
5936ed10ca
commit
e928a2ff95
26 changed files with 1022 additions and 241 deletions
417
src/apps/dashboard/routes/plugins/plugin.tsx
Normal file
417
src/apps/dashboard/routes/plugins/plugin.tsx
Normal file
|
@ -0,0 +1,417 @@
|
|||
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, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
|
||||
|
||||
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';
|
||||
|
||||
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 [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
|
||||
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
|
||||
const [ pluginDetails, setPluginDetails ] = useState<PluginDetails>();
|
||||
const [ pluginName, setPluginName ] = useState<string>();
|
||||
|
||||
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 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);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
enablePlugin.mutate({
|
||||
pluginId: pluginDetails.id,
|
||||
version: pluginDetails.version.version
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsEnabledOverride(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [ disablePlugin, enablePlugin, pluginDetails ]);
|
||||
|
||||
/** 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);
|
||||
}
|
||||
});
|
||||
}, [ installPlugin, pluginDetails ]);
|
||||
|
||||
/** 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
|
||||
});
|
||||
}, [ pluginDetails, uninstallPlugin ]);
|
||||
|
||||
/** Close the uninstall confirmation dialog */
|
||||
const onCloseUninstallConfirmDialog = useCallback(() => {
|
||||
setIsUninstallConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading);
|
||||
}, [ isConfigurationPagesLoading, isPackageInfoLoading, isPluginsLoading ]);
|
||||
|
||||
useEffect(() => {
|
||||
setPluginName(searchParams.get('name') ?? undefined);
|
||||
}, [ searchParams ]);
|
||||
|
||||
useEffect(() => {
|
||||
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 = api?.axiosInstance.getUri({
|
||||
baseURL: api.basePath,
|
||||
url: `/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`
|
||||
});
|
||||
}
|
||||
|
||||
setPluginDetails({
|
||||
canUninstall: !!pluginInfo?.CanUninstall,
|
||||
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
|
||||
hasConfiguration: !!configurationPages?.some(page => page.PluginId === pluginId),
|
||||
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,
|
||||
version,
|
||||
versions: packageInfo?.versions || []
|
||||
});
|
||||
}
|
||||
}, [ api, configurationPages, isEnabledOverride, isPluginsLoading, pluginId, packageInfo, pluginName, plugins ]);
|
||||
|
||||
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?.hasConfiguration && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/${getPluginUrl(pluginName)}`}
|
||||
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