diff --git a/src/apps/dashboard/components/BaseCard.tsx b/src/apps/dashboard/components/BaseCard.tsx index f6e6831462..8209ec3f0c 100644 --- a/src/apps/dashboard/components/BaseCard.tsx +++ b/src/apps/dashboard/components/BaseCard.tsx @@ -9,6 +9,7 @@ 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'; +import { Link, To } from 'react-router-dom'; interface IProps { title?: string; @@ -16,13 +17,14 @@ interface IProps { text?: string; image?: string | null; icon?: React.ReactNode; + to?: To; onClick?: () => void; action?: boolean; actionRef?: React.MutableRefObject; onActionClick?: () => void; }; -const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, actionRef, onActionClick }: IProps) => { +const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: IProps) => { return ( - + {image ? ( -
-
-
-

${TabCatalog}

- - - -
-
- -
-
${MessageNoAvailablePlugins}
-
-
-
- diff --git a/src/apps/dashboard/controllers/plugins/available/index.js b/src/apps/dashboard/controllers/plugins/available/index.js deleted file mode 100644 index f8b05197b5..0000000000 --- a/src/apps/dashboard/controllers/plugins/available/index.js +++ /dev/null @@ -1,163 +0,0 @@ -import escapeHTML from 'escape-html'; - -import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; -import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils'; -import loading from 'components/loading/loading'; -import globalize from 'lib/globalize'; - -import 'components/cardbuilder/card.scss'; -import 'elements/emby-button/emby-button'; -import 'elements/emby-checkbox/emby-checkbox'; -import 'elements/emby-select/emby-select'; - -function reloadList(page) { - loading.show(); - const promise1 = ApiClient.getAvailablePlugins(); - const promise2 = ApiClient.getInstalledPlugins(); - Promise.all([promise1, promise2]).then(function (responses) { - populateList({ - catalogElement: page.querySelector('#pluginTiles'), - noItemsElement: page.querySelector('#noPlugins'), - availablePlugins: responses[0], - installedPlugins: responses[1] - }); - }); -} - -function getHeaderText(category) { - const categoryKey = category.replaceAll(' ', ''); - - if (CATEGORY_LABELS[categoryKey]) { - return globalize.translate(CATEGORY_LABELS[categoryKey]); - } - - console.warn('[AvailablePlugins] unmapped category label', category); - return category; -} - -function populateList(options) { - const availablePlugins = options.availablePlugins; - const installedPlugins = options.installedPlugins; - - availablePlugins.forEach(function (plugin, index, array) { - plugin.category = plugin.category || 'Other'; - plugin.categoryDisplayName = getHeaderText(plugin.category); - array[index] = plugin; - }); - - availablePlugins.sort(function (a, b) { - if (a.category > b.category) { - return 1; - } else if (b.category > a.category) { - return -1; - } - if (a.name > b.name) { - return 1; - } else if (b.name > a.name) { - return -1; - } - return 0; - }); - - let currentCategory = null; - let html = ''; - - for (const plugin of availablePlugins) { - const category = plugin.categoryDisplayName; - if (category != currentCategory) { - if (currentCategory) { - html += ''; - html += ''; - } - html += '
'; - html += '

' + escapeHTML(category) + '

'; - html += '
'; - currentCategory = category; - } - html += getPluginHtml(plugin, options, installedPlugins); - } - html += '
'; - html += '
'; - - if (!availablePlugins.length && options.noItemsElement) { - options.noItemsElement.classList.remove('hide'); - } - - const searchBar = document.getElementById('txtSearchPlugins'); - if (searchBar) { - searchBar.addEventListener('input', () => onSearchBarType(searchBar)); - } - - options.catalogElement.innerHTML = html; - loading.hide(); -} - -function onSearchBarType(searchBar) { - const filter = searchBar.value.toLowerCase(); - for (const header of document.querySelectorAll('div .verticalSection')) { - // keep track of shown cards after each search - let shown = 0; - for (const card of header.querySelectorAll('div .card')) { - if (filter && filter != '' && !card.textContent.toLowerCase().includes(filter)) { - card.style.display = 'none'; - } else { - card.style.display = 'unset'; - shown++; - } - } - // hide title if no cards are shown - if (shown <= 0) { - header.style.display = 'none'; - } else { - header.style.display = 'unset'; - } - } -} - -function getPluginHtml(plugin, options, installedPlugins) { - let html = ''; - let href = plugin.externalUrl ? plugin.externalUrl : - `#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`; - - if (options.context) { - href += '&context=' + options.context; - } - - const target = plugin.externalUrl ? ' target="_blank"' : ''; - html += "
"; - html += '
'; - html += ''; - html += '
'; - html += "
"; - html += escapeHTML(plugin.name); - html += '
'; - const installedPlugin = installedPlugins.find(installed => installed.Id === plugin.guid); - html += "
"; - html += installedPlugin ? globalize.translate('LabelVersionInstalled', installedPlugin.Version) : ' '; - html += '
'; - html += '
'; - html += '
'; - html += '
'; - return html; -} - -export default function (view) { - view.addEventListener('viewshow', function () { - reloadList(this); - }); -} diff --git a/src/apps/dashboard/features/plugins/api/queryKey.ts b/src/apps/dashboard/features/plugins/api/queryKey.ts index c999d3407c..f63c94987e 100644 --- a/src/apps/dashboard/features/plugins/api/queryKey.ts +++ b/src/apps/dashboard/features/plugins/api/queryKey.ts @@ -1,6 +1,6 @@ export enum QueryKey { ConfigurationPages = 'ConfigurationPages', - PackageInfo = 'PackageInfo', + Packages = 'Packages', Plugins = 'Plugins', Repositories = 'Repositories' } diff --git a/src/apps/dashboard/features/plugins/api/usePackageInfo.ts b/src/apps/dashboard/features/plugins/api/usePackageInfo.ts index e98ac50d3b..43ef3f9372 100644 --- a/src/apps/dashboard/features/plugins/api/usePackageInfo.ts +++ b/src/apps/dashboard/features/plugins/api/usePackageInfo.ts @@ -7,12 +7,24 @@ import type { AxiosRequestConfig } from 'axios'; import { useApi } from 'hooks/useApi'; import { QueryKey } from './queryKey'; +import { queryClient } from 'utils/query/queryClient'; +import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info'; const fetchPackageInfo = async ( api: Api, params: PackageApiGetPackageInfoRequest, options?: AxiosRequestConfig ) => { + const packagesData = queryClient.getQueryData([ QueryKey.Packages ]) as PackageInfo[]; + if (packagesData && params.assemblyGuid) { + // Use cached query to avoid re-fetching + const pkg = packagesData.find(v => v.guid === params.assemblyGuid); + + if (pkg) { + return pkg; + } + } + const response = await getPackageApi(api) .getPackageInfo(params, options); return response.data; @@ -24,7 +36,7 @@ const getPackageInfoQuery = ( ) => queryOptions({ // Don't retry since requests for plugins not available in repos fail retry: false, - queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ], + queryKey: [ QueryKey.Packages, params?.name, params?.assemblyGuid ], queryFn: ({ signal }) => fetchPackageInfo(api!, params!, { signal }), enabled: !!params && !!api && !!params.name }); diff --git a/src/apps/dashboard/features/plugins/api/usePackages.ts b/src/apps/dashboard/features/plugins/api/usePackages.ts new file mode 100644 index 0000000000..45fe3e76ed --- /dev/null +++ b/src/apps/dashboard/features/plugins/api/usePackages.ts @@ -0,0 +1,32 @@ +import type { Api } from '@jellyfin/sdk'; +import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import type { AxiosRequestConfig } from 'axios'; + +import { useApi } from 'hooks/useApi'; + +import { QueryKey } from './queryKey'; + +const fetchPackages = async ( + api: Api, + options?: AxiosRequestConfig +) => { + const response = await getPackageApi(api) + .getPackages(options); + return response.data; +}; + +const getPackagesQuery = ( + api?: Api +) => queryOptions({ + queryKey: [ QueryKey.Packages ], + queryFn: ({ signal }) => fetchPackages(api!, { signal }), + enabled: !!api, + staleTime: 15 * 60 * 1000 // 15 minutes +}); + +export const usePackages = () => { + const { api } = useApi(); + return useQuery(getPackagesQuery(api)); +}; + diff --git a/src/apps/dashboard/features/plugins/components/PackageCard.tsx b/src/apps/dashboard/features/plugins/components/PackageCard.tsx new file mode 100644 index 0000000000..0145a2eacb --- /dev/null +++ b/src/apps/dashboard/features/plugins/components/PackageCard.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import BaseCard from 'apps/dashboard/components/BaseCard'; +import { useLocation } from 'react-router-dom'; + +type IProps = { + pkg: PackageInfo; +}; + +const PackageCard = ({ pkg }: IProps) => { + const location = useLocation(); + + return ( + } + to={{ + pathname: `/dashboard/plugins/${pkg.guid}`, + search: `?name=${encodeURIComponent(pkg.name || '')}`, + hash: location.hash + }} + /> + ); +}; + +export default PackageCard; diff --git a/src/apps/dashboard/features/plugins/components/PluginCard.tsx b/src/apps/dashboard/features/plugins/components/PluginCard.tsx index 08bd40bbe2..5ea7eef539 100644 --- a/src/apps/dashboard/features/plugins/components/PluginCard.tsx +++ b/src/apps/dashboard/features/plugins/components/PluginCard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +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'; @@ -37,14 +37,16 @@ const PluginCard = ({ plugin, configurationPage }: IProps) => { const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false); const { api } = useApi(); - const navigateToPluginSettings = useCallback(() => { - if (configurationPage) { - navigate({ - pathname: '/configurationpage', - search: `?name=${encodeURIComponent(configurationPage.Name || '')}`, - hash: location.hash - }); + const pluginPage = useMemo(() => ( + { + pathname: '/configurationpage', + search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`, + hash: location.hash } + ), [ location, configurationPage ]); + + const navigateToPluginSettings = useCallback(() => { + navigate(pluginPage); }, [ navigate, location, configurationPage ]); const onEnablePlugin = useCallback(() => { @@ -105,12 +107,12 @@ const PluginCard = ({ plugin, configurationPage }: IProps) => { } action={true} actionRef={actionRef} - onClick={navigateToPluginSettings} onActionClick={onActionClick} /> { + if (!packages) return []; + + const categories: string[] = []; + + for (const pkg of packages) { + if (pkg.category && !categories.includes(pkg.category)) { + categories.push(pkg.category); + } + } + + return categories.sort((a, b) => a.localeCompare(b)); +}; + +export default getPackageCategories; diff --git a/src/apps/dashboard/features/plugins/utils/getPackagesByCategory.ts b/src/apps/dashboard/features/plugins/utils/getPackagesByCategory.ts new file mode 100644 index 0000000000..afcbb7adca --- /dev/null +++ b/src/apps/dashboard/features/plugins/utils/getPackagesByCategory.ts @@ -0,0 +1,15 @@ +import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info'; + +const getPackagesByCategory = (packages: PackageInfo[] | undefined, category: string) => { + if (!packages) return []; + + return packages.filter(pkg => pkg.category == category).sort((a, b) => { + if (a.name && b.name) { + return a.name?.localeCompare(b.name); + } else { + return 0; + } + }); +}; + +export default getPackagesByCategory; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 4617c19822..e1728b226d 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -19,6 +19,7 @@ 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 }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index 8d0a956792..d5d7f4ce2a 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -23,13 +23,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'library', view: 'library.html' } - }, { - path: 'plugins/catalog', - pageProps: { - appType: AppType.Dashboard, - controller: 'plugins/available/index', - view: 'plugins/available/index.html' - } }, { path: 'livetv/guide', pageProps: { diff --git a/src/apps/dashboard/routes/plugins/catalog.tsx b/src/apps/dashboard/routes/plugins/catalog.tsx new file mode 100644 index 0000000000..b2106d8fed --- /dev/null +++ b/src/apps/dashboard/routes/plugins/catalog.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import Page from 'components/Page'; +import globalize from 'lib/globalize'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages'; +import Loading from 'components/loading/LoadingComponent'; +import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories'; +import Stack from '@mui/material/Stack'; +import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory'; +import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard'; +import Grid from '@mui/material/Grid2'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import Settings from '@mui/icons-material/Settings'; +import { Link } from 'react-router-dom'; + +export const Component = () => { + const { data: packages, isPending: isPackagesPending } = usePackages(); + const [ searchQuery, setSearchQuery ] = useState(''); + + const filteredPackages = useMemo(() => { + return packages?.filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase())); + }, [ packages, searchQuery ]); + + const packageCategories = getPackageCategories(filteredPackages); + + const updateSearchQuery = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + }, []); + + if (isPackagesPending) { + return ; + } + + return ( + + + + + {globalize.translate('TabCatalog')} + + + + + + + + {packageCategories.map(category => ( + + {category} + + + {getPackagesByCategory(filteredPackages, category).map(pkg => ( + + + + ))} + + + ))} + + + + ); +}; + +Component.displayName = 'PluginsCatalogPage';