Merge pull request #6921 from viown/react-plugins-installed

Migrate installed plugins page to React
This commit is contained in:
Bill Thornton
2025-06-09 17:36:25 -04:00
committed by GitHub
9 changed files with 342 additions and 272 deletions
@@ -0,0 +1,84 @@
import React from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardMedia from '@mui/material/CardMedia';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import CardActionArea from '@mui/material/CardActionArea';
import Stack from '@mui/material/Stack';
interface IProps {
title?: string;
secondaryTitle?: string;
text?: string;
image?: string | null;
icon?: React.ReactNode;
onClick?: () => void;
action?: boolean;
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
onActionClick?: () => void;
};
const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, actionRef, onActionClick }: IProps) => {
return (
<Card
sx={{
display: 'flex',
flexDirection: 'column',
height: 240
}}
>
<CardActionArea onClick={onClick} sx={{
display: 'flex',
flexGrow: 1,
alignItems: 'stretch'
}}>
{image ? (
<CardMedia
sx={{ flexGrow: 1 }}
image={image}
title={title}
/>
) : (
<Box className={getDefaultBackgroundClass(title)} sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{icon}
</Box>
)}
</CardActionArea>
<CardHeader
title={
<Stack direction='row' gap={1} alignItems='center'>
<Typography sx={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis'
}}>
{title}
</Typography>
{secondaryTitle && (
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
)}
</Stack>
}
subheader={text}
action={
action ? (
<IconButton ref={actionRef} onClick={onActionClick}>
<MoreVertIcon />
</IconButton>
) : null
}
/>
</Card>
);
};
export default BaseCard;
@@ -1,13 +0,0 @@
<div id="pluginsPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabPlugins}">
<div>
<div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabMyPlugins}</h2>
</div>
<div class="inputContainer">
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
</div>
<div class="installedPlugins"></div>
</div>
</div>
</div>
@@ -1,251 +0,0 @@
import loading from 'components/loading/loading';
import dom from 'scripts/dom';
import globalize from 'lib/globalize';
import 'components/cardbuilder/card.scss';
import 'elements/emby-button/emby-button';
import Dashboard, { pageIdOn } from 'utils/dashboard';
import confirm from 'components/confirm/confirm';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
function deletePlugin(page, uniqueid, version, name) {
const msg = globalize.translate('UninstallPluginConfirmation', name);
confirm({
title: globalize.translate('HeaderUninstallPlugin'),
text: msg,
primary: 'delete',
confirmText: globalize.translate('HeaderUninstallPlugin')
}).then(function () {
loading.show();
ApiClient.uninstallPluginByVersion(uniqueid, version).then(function () {
reloadList(page);
});
});
}
function enablePlugin(page, uniqueid, version) {
loading.show();
ApiClient.enablePlugin(uniqueid, version).then(function () {
reloadList(page);
});
}
function disablePlugin(page, uniqueid, version) {
loading.show();
ApiClient.disablePlugin(uniqueid, version).then(function () {
reloadList(page);
});
}
function showNoConfigurationMessage() {
Dashboard.alert({
message: globalize.translate('MessageNoPluginConfiguration')
});
}
function showConnectMessage() {
Dashboard.alert({
message: globalize.translate('MessagePluginConfigurationRequiresLocalAccess')
});
}
function getPluginCardHtml(plugin, pluginConfigurationPages) {
const configPage = pluginConfigurationPages.filter(function (pluginConfigurationPage) {
return pluginConfigurationPage.PluginId == plugin.Id;
})[0];
const configPageUrl = configPage ? Dashboard.getPluginUrl(configPage.Name) : null;
let html = '';
html += `<div data-id='${plugin.Id}' data-version='${plugin.Version}' data-name='${plugin.Name}' data-removable='${plugin.CanUninstall}' data-status='${plugin.Status}' class='card backdropCard'>`;
html += '<div class="cardBox visualCardBox">';
html += '<div class="cardScalable">';
html += '<div class="cardPadder cardPadder-backdrop"></div>';
html += '<div class="cardContent">';
if (configPageUrl) {
html += `<a class="cardImageContainer" is="emby-linkbutton" style="margin:0;padding:0" href="${configPageUrl}">`;
} else {
html += '<div class="cardImageContainer noConfigPluginCard noHoverEffect emby-button" style="margin:0;padding:0">';
}
if (plugin.HasImage) {
const imageUrl = ApiClient.getUrl(`/Plugins/${plugin.Id}/${plugin.Version}/Image`);
html += `<img src="${imageUrl}" style="width:100%" />`;
} else {
html += `<div class="cardImage flex align-items-center justify-content-center ${getDefaultBackgroundClass()}">`;
html += '<span class="cardImageIcon material-icons extension" aria-hidden="true"></span>';
html += '</div>';
}
html += configPageUrl ? '</a>' : '</div>';
html += '</div>';
html += '</div>';
html += '<div class="cardFooter">';
if (configPage || plugin.CanUninstall) {
if (globalize.getIsRTL()) {
html += '<div style="text-align:left; float:left;padding-top:5px;">';
} else {
html += '<div style="text-align:right; float:right;padding-top:5px;">';
}
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
html += '</div>';
}
html += '<div class="cardText">';
html += `${plugin.Name}<span class='cardText cardText-secondary'>${plugin.Version}</span>`;
html += '</div>';
html += `<div class="cardText">${globalize.translate('LabelStatus')} ${plugin.Status}</div>`;
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
function renderPlugins(page, plugins) {
ApiClient.getJSON(ApiClient.getUrl('web/configurationpages') + '?pageType=PluginConfiguration').then(function (configPages) {
populateList(page, plugins, configPages);
});
}
function populateList(page, plugins, pluginConfigurationPages) {
plugins = plugins.sort(function (plugin1, plugin2) {
if (plugin1.Name > plugin2.Name) {
return 1;
}
return -1;
});
let html = plugins.map(function (p) {
return getPluginCardHtml(p, pluginConfigurationPages);
}).join('');
const installedPluginsElement = page.querySelector('.installedPlugins');
installedPluginsElement.removeEventListener('click', onInstalledPluginsClick);
installedPluginsElement.addEventListener('click', onInstalledPluginsClick);
if (plugins.length) {
installedPluginsElement.classList.add('itemsContainer');
installedPluginsElement.classList.add('vertical-wrap');
} else {
html += '<div class="centerMessage">';
html += '<h1>' + globalize.translate('MessageNoPluginsInstalled') + '</h1>';
html += '<p><a is="emby-linkbutton" class="button-link" href="#/dashboard/plugins/catalog">';
html += globalize.translate('MessageBrowsePluginCatalog');
html += '</a></p>';
html += '</div>';
}
// add search box listener
const searchBar = page.querySelector('#txtSearchPlugins');
if (searchBar) {
searchBar.addEventListener('input', () => onFilterType(page, searchBar));
}
installedPluginsElement.innerHTML = html;
loading.hide();
}
function showPluginMenu(page, elem) {
const card = dom.parentWithClass(elem, 'card');
const id = card.getAttribute('data-id');
const name = card.getAttribute('data-name');
const removable = card.getAttribute('data-removable');
const configHref = card.querySelector('.cardImageContainer').getAttribute('href');
const status = card.getAttribute('data-status');
const version = card.getAttribute('data-version');
const menuItems = [];
if (configHref) {
menuItems.push({
name: globalize.translate('Settings'),
id: 'open',
icon: 'mode_edit'
});
}
if (removable === 'true') {
if (status === 'Disabled') {
menuItems.push({
name: globalize.translate('EnablePlugin'),
id: 'enable',
icon: 'check_circle_outline'
});
}
if (status === 'Active') {
menuItems.push({
name: globalize.translate('DisablePlugin'),
id: 'disable',
icon: 'do_not_disturb'
});
}
menuItems.push({
name: globalize.translate('ButtonUninstall'),
id: 'delete',
icon: 'delete'
});
}
import('components/actionSheet/actionSheet').then((actionsheet) => {
actionsheet.show({
items: menuItems,
positionTo: elem,
callback: function (resultId) {
switch (resultId) {
case 'open':
Dashboard.navigate(configHref);
break;
case 'delete':
deletePlugin(page, id, version, name);
break;
case 'enable':
enablePlugin(page, id, version);
break;
case 'disable':
disablePlugin(page, id, version);
break;
}
}
});
});
}
function reloadList(page) {
loading.show();
ApiClient.getInstalledPlugins().then(function (plugins) {
renderPlugins(page, plugins);
});
}
function onInstalledPluginsClick(e) {
if (dom.parentWithClass(e.target, 'noConfigPluginCard')) {
showNoConfigurationMessage();
} else if (dom.parentWithClass(e.target, 'connectModePluginCard')) {
showConnectMessage();
} else {
const btnCardMenu = dom.parentWithClass(e.target, 'btnCardMenu');
if (btnCardMenu) {
showPluginMenu(dom.parentWithClass(btnCardMenu, 'page'), btnCardMenu);
}
}
}
function onFilterType(page, searchBar) {
const filter = searchBar.value.toLowerCase();
for (const card of page.querySelectorAll('.card')) {
if (filter && filter != '' && !card.textContent.toLowerCase().includes(filter)) {
card.style.display = 'none';
} else {
card.style.display = 'unset';
}
}
}
pageIdOn('pageshow', 'pluginsPage', function () {
reloadList(this);
});
window.PluginsPage = {
renderPlugins: renderPlugins
};
@@ -0,0 +1,170 @@
import React, { useCallback, 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';
interface IProps {
plugin: PluginInfo;
configurationPage?: ConfigurationPageInfo;
};
const PluginCard = ({ plugin, configurationPage }: IProps) => {
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 navigateToPluginSettings = useCallback(() => {
if (configurationPage) {
navigate({
pathname: '/configurationpage',
search: `?name=${encodeURIComponent(configurationPage.Name || '')}`,
hash: location.hash
});
}
}, [ navigate, location, configurationPage ]);
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);
}, []);
return (
<>
<BaseCard
title={plugin.Name}
secondaryTitle={plugin.Version}
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}
onClick={navigateToPluginSettings}
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')}
/>
</>
);
};
export default PluginCard;
@@ -16,6 +16,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'playback/resume', type: AppType.Dashboard },
{ path: 'playback/streaming', type: AppType.Dashboard },
{ path: 'playback/trickplay', type: AppType.Dashboard },
{ path: 'plugins', type: AppType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
{ path: 'tasks', type: AppType.Dashboard },
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },
@@ -72,12 +72,5 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'livetvtuner',
view: 'livetvtuner.html'
}
}, {
path: 'plugins',
pageProps: {
appType: AppType.Dashboard,
controller: 'plugins/installed/index',
view: 'plugins/installed/index.html'
}
}
];
@@ -0,0 +1,85 @@
import React, { useCallback, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
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';
export const Component = () => {
const {
data: plugins,
isPending,
isError
} = usePlugins();
const {
data: configurationPages,
isError: isConfigurationPagesError,
isPending: isConfigurationPagesPending
} = useConfigurationPages();
const [ searchQuery, setSearchQuery ] = useState('');
const onSearchChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
setSearchQuery(event.target.value);
}, []);
const filteredPlugins = useMemo(() => {
if (plugins) {
return plugins.filter(i => i.Name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
} else {
return [];
}
}, [ plugins, searchQuery ]);
if (isPending || isConfigurationPagesPending) {
return <Loading />;
}
return (
<Page
id='pluginsPage'
title={globalize.translate('TabPlugins')}
className='type-interior mainAnimatedPage'
>
<Box className='content-primary'>
{isError || isConfigurationPagesError ? (
<Alert severity='error'>{globalize.translate('PluginsLoadError')}</Alert>
) : (
<Stack spacing={3}>
<Typography variant='h1'>
{globalize.translate('TabMyPlugins')}
</Typography>
<TextField
label={globalize.translate('Search')}
value={searchQuery}
onChange={onSearchChange}
/>
<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 }}>
<PluginCard
plugin={plugin}
configurationPage={findBestConfigurationPage(configurationPages, plugin.Id || '')}
/>
</Grid>
))}
</Grid>
</Box>
</Stack>
)}
</Box>
</Page>
);
};
Component.displayName = 'InstalledPlugins';
+1 -1
View File
@@ -158,7 +158,7 @@ export const Component = () => {
helperText={(
<>
<span>{globalize.translate('LabelDisplayLanguageHelp')}</span>
<Link href='https://jellyfin.org/docs/general/contributing/#translating'>
<Link href='https://jellyfin.org/docs/general/contributing/#translating' target='_blank'>
{globalize.translate('LearnHowYouCanContribute')}
</Link>
</>
+1
View File
@@ -1399,6 +1399,7 @@
"PluginLoadConfigError": "An error occurred while getting the plugin configuration pages.",
"PluginLoadRepoError": "An error occurred while getting the plugin details from the repository.",
"PluginUninstallError": "An error occurred while uninstalling the plugin.",
"PluginsLoadError": "An error occurred while loading plugins.",
"Poster": "Poster",
"PosterCard": "Poster Card",
"PreferEmbeddedEpisodeInfosOverFileNames": "Prefer embedded episode information over filenames",