diff --git a/src/apps/dashboard/components/BaseCard.tsx b/src/apps/dashboard/components/BaseCard.tsx new file mode 100644 index 000000000..f6e683146 --- /dev/null +++ b/src/apps/dashboard/components/BaseCard.tsx @@ -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; + onActionClick?: () => void; +}; + +const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, actionRef, onActionClick }: IProps) => { + return ( + + + {image ? ( + + ) : ( + + {icon} + + )} + + + + {title} + + {secondaryTitle && ( + {secondaryTitle} + )} + + } + subheader={text} + action={ + action ? ( + + + + ) : null + } + /> + + ); +}; + +export default BaseCard; diff --git a/src/apps/dashboard/controllers/plugins/installed/index.html b/src/apps/dashboard/controllers/plugins/installed/index.html deleted file mode 100644 index b29fd6eee..000000000 --- a/src/apps/dashboard/controllers/plugins/installed/index.html +++ /dev/null @@ -1,13 +0,0 @@ -
-
-
-
-

${TabMyPlugins}

-
-
- -
-
-
-
-
diff --git a/src/apps/dashboard/controllers/plugins/installed/index.js b/src/apps/dashboard/controllers/plugins/installed/index.js deleted file mode 100644 index b3971ecf1..000000000 --- a/src/apps/dashboard/controllers/plugins/installed/index.js +++ /dev/null @@ -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 += `
`; - html += '
'; - html += ''; - html += '
'; - - if (configPage || plugin.CanUninstall) { - if (globalize.getIsRTL()) { - html += '
'; - } else { - html += '
'; - } - html += ''; - html += '
'; - } - - html += '
'; - html += `${plugin.Name}${plugin.Version}`; - html += '
'; - html += `
${globalize.translate('LabelStatus')} ${plugin.Status}
`; - html += '
'; - html += '
'; - html += '
'; - 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 += '
'; - html += '

' + globalize.translate('MessageNoPluginsInstalled') + '

'; - html += '

'; - html += globalize.translate('MessageBrowsePluginCatalog'); - html += '

'; - html += '
'; - } - - // 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 -}; diff --git a/src/apps/dashboard/features/plugins/components/PluginCard.tsx b/src/apps/dashboard/features/plugins/components/PluginCard.tsx new file mode 100644 index 000000000..08bd40bbe --- /dev/null +++ b/src/apps/dashboard/features/plugins/components/PluginCard.tsx @@ -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(null); + const enablePlugin = useEnablePlugin(); + const disablePlugin = useDisablePlugin(); + const uninstallPlugin = useUninstallPlugin(); + const [ anchorEl, setAnchorEl ] = useState(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 ( + <> + } + action={true} + actionRef={actionRef} + onClick={navigateToPluginSettings} + onActionClick={onActionClick} + /> + + {configurationPage && ( + + + + + {globalize.translate('Settings')} + + )} + + {(plugin.CanUninstall && plugin.Status === PluginStatus.Active) && ( + + + + + {globalize.translate('DisablePlugin')} + + )} + + {(plugin.CanUninstall && plugin.Status === PluginStatus.Disabled) && ( + + + + + {globalize.translate('EnablePlugin')} + + )} + + {plugin.CanUninstall && ( + + + + + {globalize.translate('ButtonUninstall')} + + )} + + + + ); +}; + +export default PluginCard; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 10d4fb838..3ffa50a97 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -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 }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index ceedb7265..aa51a6f91 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -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' - } } ]; diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx new file mode 100644 index 000000000..aacdd06ce --- /dev/null +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -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) => { + 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 ; + } + + return ( + + + {isError || isConfigurationPagesError ? ( + {globalize.translate('PluginsLoadError')} + ) : ( + + + {globalize.translate('TabMyPlugins')} + + + + + + + {filteredPlugins.map(plugin => ( + + + + ))} + + + + )} + + + ); +}; + +Component.displayName = 'InstalledPlugins'; diff --git a/src/apps/dashboard/routes/settings/index.tsx b/src/apps/dashboard/routes/settings/index.tsx index b84f26363..9726190ea 100644 --- a/src/apps/dashboard/routes/settings/index.tsx +++ b/src/apps/dashboard/routes/settings/index.tsx @@ -158,7 +158,7 @@ export const Component = () => { helperText={( <> {globalize.translate('LabelDisplayLanguageHelp')} - + {globalize.translate('LearnHowYouCanContribute')} diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 0add9274f..ecacbc722 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -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",