Add unified plugin page

This commit is contained in:
Bill Thornton
2025-07-10 10:18:18 -04:00
parent f4ec138c4f
commit a9106642bd
13 changed files with 364 additions and 219 deletions

View File

@@ -11,7 +11,7 @@ import CardActionArea from '@mui/material/CardActionArea';
import Stack from '@mui/material/Stack';
import { Link, To } from 'react-router-dom';
interface IProps {
interface BaseCardProps {
title?: string;
secondaryTitle?: string;
text?: string;
@@ -24,7 +24,7 @@ interface IProps {
onActionClick?: () => void;
};
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: IProps) => {
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: BaseCardProps) => {
return (
<Card
sx={{

View File

@@ -0,0 +1,70 @@
import Search from '@mui/icons-material/Search';
import InputBase, { type InputBaseProps } from '@mui/material/InputBase';
import { alpha, styled } from '@mui/material/styles';
import React, { type FC } from 'react';
const SearchContainer = styled('div')(({ theme }) => ({
display: 'flex',
position: 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: alpha(theme.palette.common.white, 0.25)
},
width: '100%',
[theme.breakpoints.up('sm')]: {
width: 'auto'
}
}));
const SearchIconWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 2),
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: 'inherit',
flexGrow: 1,
'& .MuiInputBase-input': {
padding: theme.spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('md')]: {
width: '20ch'
}
}
}));
interface SearchInputProps extends InputBaseProps {
label?: string
}
const SearchInput: FC<SearchInputProps> = ({
label,
...props
}) => {
return (
<SearchContainer>
<SearchIconWrapper>
<Search />
</SearchIconWrapper>
<StyledInputBase
placeholder={label}
inputProps={{
'aria-label': label,
...props.inputProps
}}
{...props}
/>
</SearchContainer>
);
};
export default SearchInput;

View File

@@ -1,6 +1,5 @@
import Extension from '@mui/icons-material/Extension';
import Folder from '@mui/icons-material/Folder';
import Public from '@mui/icons-material/Public';
import List from '@mui/material/List';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
@@ -33,23 +32,16 @@ const PluginDrawerSection = () => {
>
<ListItemLink
to='/dashboard/plugins'
includePaths={[ '/configurationpage' ]}
includePaths={[
'/configurationpage',
'/dashboard/plugins/repositories'
]}
excludePaths={pagesInfo?.map(p => `/${Dashboard.getPluginUrl(p.Name)}`)}
>
<ListItemIcon>
<Extension />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabMyPlugins')} />
</ListItemLink>
<ListItemLink
to='/dashboard/plugins/catalog'
includePaths={[ '/dashboard/plugins/repositories' ]}
>
<ListItemIcon>
<Public />
</ListItemIcon>
<ListItemText primary={globalize.translate('TabCatalog')} />
<ListItemText primary={globalize.translate('TabPlugins')} />
</ListItemLink>
{pagesInfo?.map(pageInfo => (

View File

@@ -0,0 +1,101 @@
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
import { useMemo } from 'react';
import { useApi } from 'hooks/useApi';
import { useConfigurationPages } from './useConfigurationPages';
import { usePlugins } from './usePlugins';
import type { PluginDetails } from '../types/PluginDetails';
import { findBestConfigurationPage } from './configurationPage';
import { findBestPluginInfo } from './pluginInfo';
import { usePackages } from './usePackages';
export const usePluginDetails = () => {
const { api } = useApi();
const {
data: configurationPages,
isError: isConfigurationPagesError,
isPending: isConfigurationPagesPending
} = useConfigurationPages();
const {
data: packages,
isError: isPackagesError,
isPending: isPackagesPending
} = usePackages();
const {
data: plugins,
isError: isPluginsError,
isPending: isPluginsPending
} = usePlugins();
const pluginDetails = useMemo<PluginDetails[]>(() => {
if (!isPackagesPending && !isPluginsPending) {
const pluginIds = new Set<string>();
packages?.forEach(({ guid }) => {
if (guid) pluginIds.add(guid);
});
plugins?.forEach(({ Id }) => {
if (Id) pluginIds.add(Id);
});
return Array.from(pluginIds)
.map(id => {
const packageInfo = packages?.find(pkg => pkg.guid === id);
const pluginInfo = findBestPluginInfo(id, 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?.getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`);
}
return {
canUninstall: !!pluginInfo?.CanUninstall,
category: packageInfo?.category,
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
id,
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
isEnabled: pluginInfo?.Status !== PluginStatus.Disabled,
name: pluginInfo?.Name || packageInfo?.name,
owner: packageInfo?.owner,
status: pluginInfo?.Status,
configurationPage: findBestConfigurationPage(configurationPages || [], id),
version,
versions: packageInfo?.versions || []
};
})
.sort(({ name: nameA }, { name: nameB }) => (
(nameA || '').localeCompare(nameB || '')
));
}
return [];
}, [
api,
configurationPages,
isPluginsPending,
packages,
plugins
]);
return {
data: pluginDetails,
isError: isConfigurationPagesError || isPackagesError || isPluginsError,
isPending: isConfigurationPagesPending || isPackagesPending || isPluginsPending
};
};

View File

@@ -1,171 +1,34 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useApi } from 'hooks/useApi';
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
import globalize from 'lib/globalize';
import BaseCard from 'apps/dashboard/components/BaseCard';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import Settings from '@mui/icons-material/Settings';
import Delete from '@mui/icons-material/Delete';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import BlockIcon from '@mui/icons-material/Block';
import ExtensionIcon from '@mui/icons-material/Extension';
import ListItemText from '@mui/material/ListItemText';
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
import { useEnablePlugin } from '../api/useEnablePlugin';
import { useDisablePlugin } from '../api/useDisablePlugin';
import { useUninstallPlugin } from '../api/useUninstallPlugin';
import ConfirmDialog from 'components/ConfirmDialog';
import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
interface IProps {
plugin: PluginInfo;
configurationPage?: ConfigurationPageInfo;
import BaseCard from 'apps/dashboard/components/BaseCard';
import { PluginDetails } from '../types/PluginDetails';
interface PluginCardProps {
plugin: PluginDetails;
};
const PluginCard = ({ plugin, configurationPage }: IProps) => {
const PluginCard = ({ plugin }: PluginCardProps) => {
const location = useLocation();
const navigate = useNavigate();
const actionRef = useRef<HTMLButtonElement | null>(null);
const enablePlugin = useEnablePlugin();
const disablePlugin = useDisablePlugin();
const uninstallPlugin = useUninstallPlugin();
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
const { api } = useApi();
const pluginPage = useMemo(() => (
{
pathname: '/configurationpage',
search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`,
pathname: `/dashboard/plugins/${plugin.id}`,
search: `?name=${encodeURIComponent(plugin.name || '')}`,
hash: location.hash
}
), [ location, configurationPage ]);
const navigateToPluginSettings = useCallback(() => {
navigate(pluginPage);
}, [ navigate, pluginPage ]);
const onEnablePlugin = useCallback(() => {
if (plugin.Id && plugin.Version) {
enablePlugin.mutate({
pluginId: plugin.Id,
version: plugin.Version
});
setAnchorEl(null);
setIsMenuOpen(false);
}
}, [ plugin, enablePlugin ]);
const onDisablePlugin = useCallback(() => {
if (plugin.Id && plugin.Version) {
disablePlugin.mutate({
pluginId: plugin.Id,
version: plugin.Version
});
setAnchorEl(null);
setIsMenuOpen(false);
}
}, [ plugin, disablePlugin ]);
const onCloseUninstallConfirmDialog = useCallback(() => {
setIsUninstallConfirmOpen(false);
}, []);
const showUninstallConfirmDialog = useCallback(() => {
setIsUninstallConfirmOpen(true);
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onUninstall = useCallback(() => {
if (plugin.Id && plugin.Version) {
uninstallPlugin.mutate({
pluginId: plugin.Id,
version: plugin.Version
});
setAnchorEl(null);
setIsMenuOpen(false);
}
}, [ plugin, uninstallPlugin ]);
const onMenuClose = useCallback(() => {
setAnchorEl(null);
setIsMenuOpen(false);
}, []);
const onActionClick = useCallback(() => {
setAnchorEl(actionRef.current);
setIsMenuOpen(true);
}, []);
), [ location, plugin ]);
return (
<>
<BaseCard
title={plugin.Name}
secondaryTitle={plugin.Version}
to={pluginPage}
text={`${globalize.translate('LabelStatus')} ${plugin.Status}`}
image={plugin.HasImage ? api?.getUri(`/Plugins/${plugin.Id}/${plugin.Version}/Image`) : null}
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
action={true}
actionRef={actionRef}
onActionClick={onActionClick}
/>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={onMenuClose}
>
{configurationPage && (
<MenuItem onClick={navigateToPluginSettings}>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText>{globalize.translate('Settings')}</ListItemText>
</MenuItem>
)}
{(plugin.CanUninstall && plugin.Status === PluginStatus.Active) && (
<MenuItem onClick={onDisablePlugin}>
<ListItemIcon>
<BlockIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('DisablePlugin')}</ListItemText>
</MenuItem>
)}
{(plugin.CanUninstall && plugin.Status === PluginStatus.Disabled) && (
<MenuItem onClick={onEnablePlugin}>
<ListItemIcon>
<CheckCircleOutlineIcon />
</ListItemIcon>
<ListItemText>{globalize.translate('EnablePlugin')}</ListItemText>
</MenuItem>
)}
{plugin.CanUninstall && (
<MenuItem onClick={showUninstallConfirmDialog}>
<ListItemIcon>
<Delete />
</ListItemIcon>
<ListItemText>{globalize.translate('ButtonUninstall')}</ListItemText>
</MenuItem>
)}
</Menu>
<ConfirmDialog
open={isUninstallConfirmOpen}
title={globalize.translate('HeaderUninstallPlugin')}
text={globalize.translate('UninstallPluginConfirmation', plugin.Name || '')}
onCancel={onCloseUninstallConfirmDialog}
onConfirm={onUninstall}
confirmButtonColor='error'
confirmButtonText={globalize.translate('ButtonUninstall')}
/>
</>
<BaseCard
title={plugin.name}
to={pluginPage}
text={[plugin.version?.VersionNumber, plugin.status].filter(t => t).join(' ')}
image={plugin.imageUrl}
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
/>
);
};

View File

@@ -72,6 +72,9 @@ const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
<TableCell>
{
(isRepositoryLoading && <Skeleton />)
|| (pluginDetails?.status && pluginDetails?.canUninstall === false
&& globalize.translate('LabelBundled')
)
|| (pluginDetails?.version?.repositoryUrl && (
<Link
component={RouterLink}

View File

@@ -1,13 +1,13 @@
/** A mapping of category names used by the plugin repository to translation keys. */
export const CATEGORY_LABELS: Record<string, string> = {
Administration: 'HeaderAdmin',
Anime: 'Anime',
Authentication: 'LabelAuthProvider', // Legacy
Books: 'Books',
Channel: 'Channels', // Unused?
General: 'General',
Anime: 'Anime',
// Authentication: 'LabelAuthProvider', // Legacy
Books: 'Books',
// Channel: 'Channels', // Unused?
LiveTV: 'LiveTV',
Metadata: 'LabelMetadata', // Legacy
// Metadata: 'LabelMetadata', // Legacy
MoviesAndShows: 'MoviesAndShows',
Music: 'TabMusic',
Subtitles: 'Subtitles',

View File

@@ -2,6 +2,7 @@ import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin
export interface PluginDetails {
canUninstall: boolean
category?: string
description?: string
id: string
imageUrl?: string

View File

@@ -21,7 +21,6 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'playback/trickplay', type: AppType.Dashboard },
{ path: 'plugins', type: AppType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
{ path: 'plugins/catalog', type: AppType.Dashboard },
{ path: 'plugins/repositories', type: AppType.Dashboard },
{ path: 'tasks', type: AppType.Dashboard },
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },

View File

@@ -1,29 +1,41 @@
import React, { useCallback, useMemo, useState } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid2';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import React, { useCallback, useMemo, useState } from 'react';
import PluginCard from 'apps/dashboard/features/plugins/components/PluginCard';
import Loading from 'components/loading/LoadingComponent';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Stack from '@mui/material/Stack';
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
import Loading from 'components/loading/LoadingComponent';
import Alert from '@mui/material/Alert';
import Grid from '@mui/material/Grid2';
import PluginCard from 'apps/dashboard/features/plugins/components/PluginCard';
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
import { usePluginDetails } from 'apps/dashboard/features/plugins/api/usePluginDetails';
import { Link } from 'react-router-dom';
import IconButton from '@mui/material/IconButton/IconButton';
import Settings from '@mui/icons-material/Settings';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
import SearchInput from 'apps/dashboard/components/SearchInput';
const MAIN_CATEGORIES = [
'administration',
'general',
'anime',
'books',
'livetv',
'moviesandshows',
'music',
'subtitles'
];
export const Component = () => {
const {
data: plugins,
isPending,
isError
} = usePlugins();
const {
data: configurationPages,
isError: isConfigurationPagesError,
isPending: isConfigurationPagesPending
} = useConfigurationPages();
data: pluginDetails,
isError,
isPending
} = usePluginDetails();
const [ category, setCategory ] = useState<string>();
const [ searchQuery, setSearchQuery ] = useState('');
const onSearchChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
@@ -31,14 +43,28 @@ export const Component = () => {
}, []);
const filteredPlugins = useMemo(() => {
if (plugins) {
return plugins.filter(i => i.Name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
if (pluginDetails) {
let filtered = pluginDetails;
if (category) {
if (category === 'installed') {
filtered = filtered.filter(p => p.status);
} else if (category === 'other') {
filtered = filtered.filter(p => (
p.category && !MAIN_CATEGORIES.includes(p.category.toLocaleLowerCase())
));
} else {
filtered = filtered.filter(p => p.category?.toLocaleLowerCase() === category);
}
}
return filtered
.filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
} else {
return [];
}
}, [ plugins, searchQuery ]);
}, [ category, pluginDetails, searchQuery ]);
if (isPending || isConfigurationPagesPending) {
if (isPending) {
return <Loading />;
}
@@ -49,27 +75,105 @@ export const Component = () => {
className='type-interior mainAnimatedPage'
>
<Box className='content-primary'>
{isError || isConfigurationPagesError ? (
<Alert severity='error'>{globalize.translate('PluginsLoadError')}</Alert>
{isError ? (
<Alert
severity='error'
sx={{ marginBottom: 2 }}
>
{globalize.translate('PluginsLoadError')}
</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('TabMyPlugins')}
</Typography>
<Stack spacing={2}>
<Grid
container
spacing={2}
>
<Grid size={{ xs: 12, sm: 8 }}>
<Typography
variant='h1'
component='span'
sx={{
marginRight: 2,
verticalAlign: 'middle'
}}
>
{globalize.translate('TabPlugins')}
</Typography>
<TextField
label={globalize.translate('Search')}
value={searchQuery}
onChange={onSearchChange}
/>
<IconButton
component={Link}
to='/dashboard/plugins/repositories'
>
<Settings />
</IconButton>
</Grid>
<Grid
size={{ xs: 12, sm: 4 }}
sx={{
display: 'flex',
justifyContent: 'end'
}}
>
<SearchInput
label={globalize.translate('Search')}
value={searchQuery}
onChange={onSearchChange}
/>
</Grid>
</Grid>
<Box>
<Stack
direction='row'
spacing={1}
sx={{
marginLeft: '-1rem',
marginRight: '-1rem',
paddingLeft: '1rem',
paddingRight: '1rem',
paddingBottom: {
xs: 1,
md: 0.5
},
overflowX: 'auto'
}}
>
<Chip
color={!category ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory(undefined)}
label={globalize.translate('All')}
/>
<Chip
color={category === 'installed' ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory('installed')}
label={globalize.translate('LabelInstalled')}
/>
<Divider orientation='vertical' flexItem />
{Object.keys(CATEGORY_LABELS).map(c => (
<Chip
key={c}
color={category === c.toLocaleLowerCase() ? 'primary' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setCategory(c.toLocaleLowerCase())}
label={globalize.translate(CATEGORY_LABELS[c])}
/>
))}
</Stack>
<Divider />
</Box>
<Box>
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
{filteredPlugins.map(plugin => (
<Grid key={plugin.Id} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
<Grid key={plugin.id} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
<PluginCard
plugin={plugin}
configurationPage={findBestConfigurationPage(configurationPages, plugin.Id || '')}
/>
</Grid>
))}

View File

@@ -115,7 +115,7 @@ const PluginPage: FC = () => {
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
?? pluginInfo?.Status !== PluginStatus.Disabled,
name: pluginName || pluginInfo?.Name || packageInfo?.name,
owner: packageInfo?.owner,
owner: pluginInfo?.CanUninstall === false ? 'jellyfin' : packageInfo?.owner,
status: pluginInfo?.Status,
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
version,
@@ -168,7 +168,8 @@ const PluginPage: FC = () => {
alerts.push({ messageKey: 'PluginLoadConfigError' });
}
if (isPackageInfoError) {
// Don't show package load error for built-in plugins
if (!isPluginsLoading && pluginDetails?.canUninstall && isPackageInfoError) {
alerts.push({
severity: 'warning',
messageKey: 'PluginLoadRepoError'
@@ -188,6 +189,8 @@ const PluginPage: FC = () => {
isConfigurationPagesError,
isPackageInfoError,
isPluginsError,
isPluginsLoading,
pluginDetails?.canUninstall,
uninstallPlugin.isError
]);
@@ -310,7 +313,11 @@ const PluginPage: FC = () => {
<Container className='content-primary'>
{alertMessages.map(({ severity = 'error', messageKey }) => (
<Alert key={messageKey} severity={severity}>
<Alert
key={messageKey}
severity={severity}
sx={{ marginBottom: 2 }}
>
{globalize.translate(messageKey)}
</Alert>
))}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { RouteObject } from 'react-router-dom';
import { Navigate, RouteObject } from 'react-router-dom';
import ConnectionRequired from 'components/ConnectionRequired';
import { ASYNC_ADMIN_ROUTES } from './_asyncRoutes';
@@ -26,7 +26,11 @@ export const DASHBOARD_APP_ROUTES: RouteObject[] = [
path: DASHBOARD_APP_PATHS.Dashboard,
children: [
...ASYNC_ADMIN_ROUTES.map(toAsyncPageRoute),
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute)
...LEGACY_ADMIN_ROUTES.map(toViewManagerPageRoute),
{
path: 'plugins/catalog',
element: <Navigate replace to='/dashboard/plugins' />
}
],
errorElement: <ErrorBoundary pageClasses={[ 'type-interior' ]} />
},

View File

@@ -640,6 +640,7 @@
"LabelBlastMessageIntervalHelp": "Determine the duration in seconds between blast alive messages.",
"LabelBlockContentWithTags": "Block items with tags",
"LabelBuildVersion": "Build version",
"LabelBundled": "Bundled",
"LabelBurnSubtitles": "Burn subtitles",
"LabelCache": "Cache",
"LabelCachePath": "Cache path",