diff --git a/src/apps/dashboard/components/BaseCard.tsx b/src/apps/dashboard/components/BaseCard.tsx index 8209ec3f0c..891db06373 100644 --- a/src/apps/dashboard/components/BaseCard.tsx +++ b/src/apps/dashboard/components/BaseCard.tsx @@ -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 ( ({ + 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 = ({ + label, + ...props +}) => { + return ( + + + + + + + ); +}; + +export default SearchInput; diff --git a/src/apps/dashboard/components/drawer/sections/PluginDrawerSection.tsx b/src/apps/dashboard/components/drawer/sections/PluginDrawerSection.tsx index 8316d30194..a8b347e873 100644 --- a/src/apps/dashboard/components/drawer/sections/PluginDrawerSection.tsx +++ b/src/apps/dashboard/components/drawer/sections/PluginDrawerSection.tsx @@ -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 = () => { > `/${Dashboard.getPluginUrl(p.Name)}`)} > - - - - - - - - + {pagesInfo?.map(pageInfo => ( diff --git a/src/apps/dashboard/constants/helpLinks.ts b/src/apps/dashboard/constants/helpLinks.ts index 69d55e0bdb..6cce009cce 100644 --- a/src/apps/dashboard/constants/helpLinks.ts +++ b/src/apps/dashboard/constants/helpLinks.ts @@ -22,10 +22,7 @@ export const HelpLinks = [ paths: ['/dashboard/playback/transcoding'], url: 'https://jellyfin.org/docs/general/server/transcoding' }, { - paths: [ - '/dashboard/plugins', - '/dashboard/plugins/catalog' - ], + paths: ['/dashboard/plugins'], url: 'https://jellyfin.org/docs/general/server/plugins/' }, { paths: ['/dashboard/plugins/repositories'], diff --git a/src/apps/dashboard/features/plugins/api/usePluginDetails.ts b/src/apps/dashboard/features/plugins/api/usePluginDetails.ts new file mode 100644 index 0000000000..ba1cd8b679 --- /dev/null +++ b/src/apps/dashboard/features/plugins/api/usePluginDetails.ts @@ -0,0 +1,119 @@ +import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status'; +import { useMemo } from 'react'; + +import { useApi } from 'hooks/useApi'; + +import { PluginCategory } from '../constants/pluginCategory'; +import type { PluginDetails } from '../types/PluginDetails'; + +import { findBestConfigurationPage } from './configurationPage'; +import { findBestPluginInfo } from './pluginInfo'; +import { useConfigurationPages } from './useConfigurationPages'; +import { usePackages } from './usePackages'; +import { usePlugins } from './usePlugins'; + +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(() => { + if (!isPackagesPending && !isPluginsPending) { + const pluginIds = new Set(); + 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`); + } + + let category = packageInfo?.category; + if (!packageInfo) { + switch (id) { + case 'a629c0dafac54c7e931a7174223f14c8': // AudioDB + case '8c95c4d2e50c4fb0a4f36c06ff0f9a1a': // MusicBrainz + category = PluginCategory.Music; + break; + case 'a628c0dafac54c7e9d1a7134223f14c8': // OMDb + case 'b8715ed16c4745289ad3f72deb539cd4': // TMDb + category = PluginCategory.MoviesAndShows; + break; + case '872a78491171458da6fb3de3d442ad30': // Studio Images + category = PluginCategory.General; + } + } + + return { + canUninstall: !!pluginInfo?.CanUninstall, + 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 + }; +}; diff --git a/src/apps/dashboard/features/plugins/components/NoPluginResults.tsx b/src/apps/dashboard/features/plugins/components/NoPluginResults.tsx new file mode 100644 index 0000000000..ad8c6ca121 --- /dev/null +++ b/src/apps/dashboard/features/plugins/components/NoPluginResults.tsx @@ -0,0 +1,51 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import React, { type FC } from 'react'; + +import globalize from 'lib/globalize'; + +interface NoPluginResultsProps { + isFiltered: boolean + onViewAll: () => void + query: string +} + +const NoPluginResults: FC = ({ + isFiltered, + onViewAll, + query +}) => { + return ( + + + { + query ? + globalize.translate('SearchResultsEmpty', query) : + globalize.translate('NoSubtitleSearchResultsFound') + } + + + {isFiltered && ( + + )} + + ); +}; + +export default NoPluginResults; diff --git a/src/apps/dashboard/features/plugins/components/PackageCard.tsx b/src/apps/dashboard/features/plugins/components/PackageCard.tsx deleted file mode 100644 index 0145a2eacb..0000000000 --- a/src/apps/dashboard/features/plugins/components/PackageCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 3c9d26bcaf..e39cb85e7d 100644 --- a/src/apps/dashboard/features/plugins/components/PluginCard.tsx +++ b/src/apps/dashboard/features/plugins/components/PluginCard.tsx @@ -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(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 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 ( - <> - } - action={true} - actionRef={actionRef} - 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')} - - )} - - - + t).join(' ')} + image={plugin.imageUrl} + icon={} + /> ); }; diff --git a/src/apps/dashboard/features/plugins/components/PluginDetailsTable.tsx b/src/apps/dashboard/features/plugins/components/PluginDetailsTable.tsx index 2891f0b694..e46e44bda4 100644 --- a/src/apps/dashboard/features/plugins/components/PluginDetailsTable.tsx +++ b/src/apps/dashboard/features/plugins/components/PluginDetailsTable.tsx @@ -72,6 +72,9 @@ const PluginDetailsTable: FC = ({ { (isRepositoryLoading && ) + || (pluginDetails?.status && pluginDetails?.canUninstall === false + && globalize.translate('LabelBundled') + ) || (pluginDetails?.version?.repositoryUrl && ( = { - Administration: 'HeaderAdmin', - Anime: 'Anime', - Authentication: 'LabelAuthProvider', // Legacy - Books: 'Books', - Channel: 'Channels', // Unused? - General: 'General', - LiveTV: 'LiveTV', - Metadata: 'LabelMetadata', // Legacy - MoviesAndShows: 'MoviesAndShows', - Music: 'TabMusic', - Subtitles: 'Subtitles', - Other: 'Other' +export const CATEGORY_LABELS: Record = { + [PluginCategory.Administration]: 'HeaderAdmin', + [PluginCategory.General]: 'General', + [PluginCategory.Anime]: 'Anime', + [PluginCategory.Books]: 'Books', + [PluginCategory.LiveTV]: 'LiveTV', + [PluginCategory.MoviesAndShows]: 'MoviesAndShows', + [PluginCategory.Music]: 'TabMusic', + [PluginCategory.Subtitles]: 'Subtitles', + [PluginCategory.Other]: 'Other' }; diff --git a/src/apps/dashboard/features/plugins/constants/pluginCategory.ts b/src/apps/dashboard/features/plugins/constants/pluginCategory.ts new file mode 100644 index 0000000000..9da85de96c --- /dev/null +++ b/src/apps/dashboard/features/plugins/constants/pluginCategory.ts @@ -0,0 +1,12 @@ +/** Supported plugin category values. */ +export enum PluginCategory { + Administration = 'Administration', + General = 'General', + Anime = 'Anime', + Books = 'Books', + LiveTV = 'LiveTV', + MoviesAndShows = 'MoviesAndShows', + Music = 'Music', + Subtitles = 'Subtitles', + Other = 'Other' +} diff --git a/src/apps/dashboard/features/plugins/constants/pluginStatusOption.ts b/src/apps/dashboard/features/plugins/constants/pluginStatusOption.ts new file mode 100644 index 0000000000..e301087bee --- /dev/null +++ b/src/apps/dashboard/features/plugins/constants/pluginStatusOption.ts @@ -0,0 +1,6 @@ +/** Options for filtering plugins based on the installation status. */ +export enum PluginStatusOption { + All = 'All', + Available = 'Available', + Installed = 'Installed' +} diff --git a/src/apps/dashboard/features/plugins/types/PluginDetails.ts b/src/apps/dashboard/features/plugins/types/PluginDetails.ts index 88a96080c1..a55af5b1ce 100644 --- a/src/apps/dashboard/features/plugins/types/PluginDetails.ts +++ b/src/apps/dashboard/features/plugins/types/PluginDetails.ts @@ -2,6 +2,7 @@ import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin export interface PluginDetails { canUninstall: boolean + category?: string description?: string id: string imageUrl?: string diff --git a/src/apps/dashboard/features/plugins/utils/getPackageCategories.ts b/src/apps/dashboard/features/plugins/utils/getPackageCategories.ts deleted file mode 100644 index 74a9932be6..0000000000 --- a/src/apps/dashboard/features/plugins/utils/getPackageCategories.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info'; - -const getPackageCategories = (packages?: PackageInfo[]) => { - 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 deleted file mode 100644 index fcaa21ad4e..0000000000 --- a/src/apps/dashboard/features/plugins/utils/getPackagesByCategory.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 e5822957d6..61f61f9997 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -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 }, diff --git a/src/apps/dashboard/routes/plugins/catalog.tsx b/src/apps/dashboard/routes/plugins/catalog.tsx deleted file mode 100644 index e550963387..0000000000 --- a/src/apps/dashboard/routes/plugins/catalog.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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'; -import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; - -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); - }, []); - - const getCategoryLabel = (category: string) => { - const categoryKey = category.replace(/\s/g, ''); - - if (CATEGORY_LABELS[categoryKey]) { - return globalize.translate(CATEGORY_LABELS[categoryKey]); - } - - console.warn('[AvailablePlugins] unmapped category label', category); - return category; - }; - - if (isPackagesPending) { - return ; - } - - return ( - - - - - {globalize.translate('TabCatalog')} - - - - - - - - {packageCategories.map(category => ( - - {getCategoryLabel(category)} - - - {getPackagesByCategory(filteredPackages, category).map(pkg => ( - - - - ))} - - - ))} - - - - ); -}; - -Component.displayName = 'PluginsCatalogPage'; diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index aacdd06cee..b593f2c845 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -1,44 +1,93 @@ -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 Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import React, { useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; + +import SearchInput from 'apps/dashboard/components/SearchInput'; +import { usePluginDetails } from 'apps/dashboard/features/plugins/api/usePluginDetails'; +import NoPluginResults from 'apps/dashboard/features/plugins/components/NoPluginResults'; 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 { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; +import { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory'; +import { PluginStatusOption } from 'apps/dashboard/features/plugins/constants/pluginStatusOption'; +import Loading from 'components/loading/LoadingComponent'; +import Page from 'components/Page'; +import useSearchParam from 'hooks/useSearchParam'; +import globalize from 'lib/globalize'; + +/** + * The list of primary/main categories. + * Any category not in this list will be added to the "other" category. + */ +const MAIN_CATEGORIES = [ + PluginCategory.Administration.toLowerCase(), + PluginCategory.General.toLowerCase(), + PluginCategory.Anime.toLowerCase(), + PluginCategory.Books.toLowerCase(), + PluginCategory.LiveTV.toLowerCase(), + PluginCategory.MoviesAndShows.toLowerCase(), + PluginCategory.Music.toLowerCase(), + PluginCategory.Subtitles.toLowerCase() +]; + +const CATEGORY_PARAM = 'category'; +const QUERY_PARAM = 'query'; +const STATUS_PARAM = 'status'; export const Component = () => { const { - data: plugins, - isPending, - isError - } = usePlugins(); - const { - data: configurationPages, - isError: isConfigurationPagesError, - isPending: isConfigurationPagesPending - } = useConfigurationPages(); - const [ searchQuery, setSearchQuery ] = useState(''); + data: pluginDetails, + isError, + isPending + } = usePluginDetails(); + const [ category, setCategory ] = useSearchParam(CATEGORY_PARAM); + const [ searchQuery, setSearchQuery ] = useSearchParam(QUERY_PARAM); + const [ status, setStatus ] = useSearchParam(STATUS_PARAM, PluginStatusOption.Installed); const onSearchChange = useCallback((event: React.ChangeEvent) => { setSearchQuery(event.target.value); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const onViewAll = useCallback(() => { + if (category) setCategory(''); + else setStatus(PluginStatusOption.All); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ category ]); + const filteredPlugins = useMemo(() => { - if (plugins) { - return plugins.filter(i => i.Name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase())); + if (pluginDetails) { + let filtered = pluginDetails; + + if (status === PluginStatusOption.Installed) { + filtered = filtered.filter(p => p.status); + } else if (status === PluginStatusOption.Available) { + filtered = filtered.filter(p => !p.status); + } + + if (category) { + if (category === PluginCategory.Other.toLowerCase()) { + filtered = filtered.filter(p => ( + p.category && !MAIN_CATEGORIES.includes(p.category.toLowerCase()) + )); + } else { + filtered = filtered.filter(p => p.category?.toLowerCase() === category); + } + } + return filtered + .filter(i => i.name?.toLowerCase().includes(searchQuery.toLowerCase())); } else { return []; } - }, [ plugins, searchQuery ]); + }, [ category, pluginDetails, searchQuery, status ]); - if (isPending || isConfigurationPagesPending) { + if (isPending) { return ; } @@ -49,31 +98,161 @@ export const Component = () => { className='type-interior mainAnimatedPage' > - {isError || isConfigurationPagesError ? ( - {globalize.translate('PluginsLoadError')} + {isError ? ( + + {globalize.translate('PluginsLoadError')} + ) : ( - - - {globalize.translate('TabMyPlugins')} - + + + + {globalize.translate('TabPlugins')} + - + + + + + + - - {filteredPlugins.map(plugin => ( - - - + + setStatus(PluginStatusOption.All)} + label={globalize.translate('All')} + /> + + setStatus(PluginStatusOption.Available)} + label={globalize.translate('LabelAvailable')} + /> + + setStatus(PluginStatusOption.Installed)} + label={globalize.translate('LabelInstalled')} + /> + + + + setCategory('')} + label={globalize.translate('All')} + /> + + {Object.values(PluginCategory).map(c => ( + setCategory(c.toLowerCase())} + label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])} + /> ))} - + + + + + + {filteredPlugins.length > 0 ? ( + // NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs + // eslint-disable-next-line @typescript-eslint/no-deprecated + + {filteredPlugins.map(plugin => ( + // NOTE: Legacy Grid is required due to lack of gap support in JMP on some OSs + // eslint-disable-next-line @typescript-eslint/no-deprecated + + + + ))} + + ) : ( + + )} )} diff --git a/src/apps/dashboard/routes/plugins/plugin.tsx b/src/apps/dashboard/routes/plugins/plugin.tsx index e847b9385e..57abf99604 100644 --- a/src/apps/dashboard/routes/plugins/plugin.tsx +++ b/src/apps/dashboard/routes/plugins/plugin.tsx @@ -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 = () => { {alertMessages.map(({ severity = 'error', messageKey }) => ( - + {globalize.translate(messageKey)} ))} diff --git a/src/apps/dashboard/routes/routes.tsx b/src/apps/dashboard/routes/routes.tsx index 035bcc3522..0765fcfc3f 100644 --- a/src/apps/dashboard/routes/routes.tsx +++ b/src/apps/dashboard/routes/routes.tsx @@ -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: + } ], errorElement: }, diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index f06ce927d8..b2fab2849c 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -1,49 +1,26 @@ -import React, { type FC, useEffect, useState } from 'react'; +import type { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import React, { type FC } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useDebounceValue } from 'usehooks-ts'; -import { usePrevious } from 'hooks/usePrevious'; -import globalize from 'lib/globalize'; -import Page from 'components/Page'; + import SearchFields from 'apps/stable/features/search/components/SearchFields'; -import SearchSuggestions from 'apps/stable/features/search/components/SearchSuggestions'; import SearchResults from 'apps/stable/features/search/components/SearchResults'; -import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import SearchSuggestions from 'apps/stable/features/search/components/SearchSuggestions'; +import Page from 'components/Page'; +import useSearchParam from 'hooks/useSearchParam'; +import globalize from 'lib/globalize'; const COLLECTION_TYPE_PARAM = 'collectionType'; const PARENT_ID_PARAM = 'parentId'; const QUERY_PARAM = 'query'; const Search: FC = () => { - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const parentIdQuery = searchParams.get(PARENT_ID_PARAM) || undefined; const collectionTypeQuery = (searchParams.get(COLLECTION_TYPE_PARAM) || undefined) as CollectionType | undefined; - const urlQuery = searchParams.get(QUERY_PARAM) || ''; - const [query, setQuery] = useState(urlQuery); - const prevQuery = usePrevious(query, ''); + const [ query, setQuery ] = useSearchParam(QUERY_PARAM); const [debouncedQuery] = useDebounceValue(query, 500); - useEffect(() => { - if (query !== prevQuery) { - if (query === '' && urlQuery !== '') { - // The query input has been cleared; remove the url param - searchParams.delete(QUERY_PARAM); - setSearchParams(searchParams, { replace: true }); - } else if (query !== urlQuery) { - // Update the query url param value - searchParams.set(QUERY_PARAM, query); - setSearchParams(searchParams, { replace: true }); - } - } else if (query !== urlQuery) { - // Update the query if the query url param has changed - if (!urlQuery) { - searchParams.delete(QUERY_PARAM); - setSearchParams(searchParams, { replace: true }); - } - - setQuery(urlQuery); - } - }, [query, prevQuery, searchParams, setSearchParams, urlQuery]); - return ( [ string, React.Dispatch> ] = ( + param, + defaultValue = '' +) => { + const [ searchParams, setSearchParams ] = useSearchParams(); + const urlValue = searchParams.get(param) || defaultValue; + const [ value, setValue ] = useState(urlValue); + const previousValue = usePrevious(value, defaultValue); + + useEffect(() => { + if (value !== previousValue) { + if (value === defaultValue && urlValue !== defaultValue) { + // The query input has been cleared; remove the url param + searchParams.delete(param); + setSearchParams(searchParams, { replace: true }); + } else if (value !== urlValue) { + // Update the query url param value + searchParams.set(param, value); + setSearchParams(searchParams, { replace: true }); + } + } else if (value !== urlValue) { + // Update the query if the query url param has changed + if (!urlValue) { + searchParams.delete(param); + setSearchParams(searchParams, { replace: true }); + } + + setValue(urlValue); + } + }, [ value, previousValue, searchParams, setSearchParams, urlValue ]); + + return [ value, setValue ]; +}; + +export default useSearchParam; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 9dd4573882..8bfb5b80cf 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -626,6 +626,7 @@ "LabelAutomaticallyRefreshInternetMetadataEvery": "Automatically refresh metadata from the internet", "LabelAutomaticDiscovery": "Enable Auto Discovery", "LabelAutomaticDiscoveryHelp": "Allow applications to automatically detect Jellyfin by using UDP port 7359.", + "LabelAvailable": "Available", "LabelBackupsUnavailable": "No backups available", "LabelBaseUrl": "Base URL", "LabelBaseUrlHelp": "Add a custom subdirectory to the server URL. For example: http://example.com/<baseurl>", @@ -640,6 +641,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", @@ -1073,6 +1075,7 @@ "LyricDownloadersHelp": "Enable and rank your preferred lyric downloaders in order of priority.", "ManageLibrary": "Manage library", "ManageRecording": "Manage recording", + "ManageRepositories": "Manage Repositories", "MapChannels": "Map Channels", "MarkPlayed": "Mark played", "MarkUnplayed": "Mark unplayed", @@ -1126,7 +1129,6 @@ "MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?", "MessageBackupDisclaimer": "Depending on the size of your library, the backup process may take a while.", "MessageBackupInProgress": "Backup in progress", - "MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.", "MessageCancelSeriesTimerError": "An error occurred while canceling the series timer", "MessageCancelTimerError": "An error occurred while canceling the timer", "MessageChangeRecordingPath": "Changing your recording folder will not migrate existing recordings from the old location to the new. You'll need to move them manually if desired.", @@ -1159,13 +1161,11 @@ "MessageLeaveEmptyToInherit": "Leave empty to inherit settings from a parent item or the global default value.", "MessageNoItemsAvailable": "No Items are currently available.", "MessageNoFavoritesAvailable": "No favorites are currently available.", - "MessageNoAvailablePlugins": "No available plugins.", "MessageNoCollectionsAvailable": "Collections allow you to enjoy personalized groupings of Movies, Series, and Albums. Click the '+' button to start creating collections.", "MessageNoGenresAvailable": "Enable some metadata providers to pull genres from the internet.", "MessageNoMovieSuggestionsAvailable": "No movie suggestions are currently available. Start watching and rating your movies, and then come back to view your recommendations.", "MessageNoNextUpItems": "None found. Start watching your shows!", "MessageNoPluginConfiguration": "This plugin has no settings to set up.", - "MessageNoPluginsInstalled": "You have no plugins installed.", "MessageNoRepositories": "No repositories.", "MessageNoServersAvailable": "No servers have been found using the automatic server discovery.", "MessageNothingHere": "Nothing here.", @@ -1604,12 +1604,10 @@ "TabAccess": "Access", "TabAdvanced": "Advanced", "HeaderBackups": "Backups", - "TabCatalog": "Catalog", "TabDashboard": "Dashboard", "TabLatest": "Recently Added", "TabLogs": "Logs", "TabMusic": "Music", - "TabMyPlugins": "My Plugins", "TabNetworking": "Networking", "TabNetworks": "TV Networks", "TabNfoSettings": "NFO Settings", @@ -1700,6 +1698,7 @@ "VideoAudio": "Video Audio", "ViewAlbum": "View album", "ViewAlbumArtist": "View album artist", + "ViewAllPlugins": "View all plugins", "ViewLyrics": "View lyrics", "ViewPlaybackInfo": "View playback info", "ViewSettings": "View settings",