From a9106642bd55986edcbcdc6522dd27a312e25004 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Thu, 10 Jul 2025 10:18:18 -0400 Subject: [PATCH 01/10] Add unified plugin page --- src/apps/dashboard/components/BaseCard.tsx | 4 +- src/apps/dashboard/components/SearchInput.tsx | 70 +++++++ .../drawer/sections/PluginDrawerSection.tsx | 18 +- .../features/plugins/api/usePluginDetails.ts | 101 ++++++++++ .../plugins/components/PluginCard.tsx | 175 ++--------------- .../plugins/components/PluginDetailsTable.tsx | 3 + .../plugins/constants/categoryLabels.ts | 10 +- .../features/plugins/types/PluginDetails.ts | 1 + src/apps/dashboard/routes/_asyncRoutes.ts | 1 - src/apps/dashboard/routes/plugins/index.tsx | 178 ++++++++++++++---- src/apps/dashboard/routes/plugins/plugin.tsx | 13 +- src/apps/dashboard/routes/routes.tsx | 8 +- src/strings/en-us.json | 1 + 13 files changed, 364 insertions(+), 219 deletions(-) create mode 100644 src/apps/dashboard/components/SearchInput.tsx create mode 100644 src/apps/dashboard/features/plugins/api/usePluginDetails.ts 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/features/plugins/api/usePluginDetails.ts b/src/apps/dashboard/features/plugins/api/usePluginDetails.ts new file mode 100644 index 0000000000..b2e7a75cc8 --- /dev/null +++ b/src/apps/dashboard/features/plugins/api/usePluginDetails.ts @@ -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(() => { + 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`); + } + + 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 + }; +}; 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', + Anime: 'Anime', + // Authentication: 'LabelAuthProvider', // Legacy + Books: 'Books', + // Channel: 'Channels', // Unused? LiveTV: 'LiveTV', - Metadata: 'LabelMetadata', // Legacy + // Metadata: 'LabelMetadata', // Legacy MoviesAndShows: 'MoviesAndShows', Music: 'TabMusic', Subtitles: 'Subtitles', 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/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/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index aacdd06cee..2341e31ac5 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -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(); const [ searchQuery, setSearchQuery ] = useState(''); const onSearchChange = useCallback((event: React.ChangeEvent) => { @@ -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 ; } @@ -49,27 +75,105 @@ export const Component = () => { className='type-interior mainAnimatedPage' > - {isError || isConfigurationPagesError ? ( - {globalize.translate('PluginsLoadError')} + {isError ? ( + + {globalize.translate('PluginsLoadError')} + ) : ( - - - {globalize.translate('TabMyPlugins')} - + + + + + {globalize.translate('TabPlugins')} + - + + + + + + + + + + + + + setCategory(undefined)} + label={globalize.translate('All')} + /> + + setCategory('installed')} + label={globalize.translate('LabelInstalled')} + /> + + + + {Object.keys(CATEGORY_LABELS).map(c => ( + setCategory(c.toLocaleLowerCase())} + label={globalize.translate(CATEGORY_LABELS[c])} + /> + ))} + + + {filteredPlugins.map(plugin => ( - + ))} 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/strings/en-us.json b/src/strings/en-us.json index 9dd4573882..71acbbdd2b 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -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", From 0eeed43d8521f202b4bf6fe0e774cb7be69c96c6 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 11 Jul 2025 11:59:33 -0400 Subject: [PATCH 02/10] Add categories for built-in plugins --- .../features/plugins/api/usePluginDetails.ts | 24 ++++++- .../plugins/constants/categoryLabels.ts | 25 ++++---- .../plugins/constants/pluginCategory.ts | 12 ++++ src/apps/dashboard/routes/plugins/catalog.tsx | 32 +++++----- src/apps/dashboard/routes/plugins/index.tsx | 63 +++++++++++-------- 5 files changed, 98 insertions(+), 58 deletions(-) create mode 100644 src/apps/dashboard/features/plugins/constants/pluginCategory.ts diff --git a/src/apps/dashboard/features/plugins/api/usePluginDetails.ts b/src/apps/dashboard/features/plugins/api/usePluginDetails.ts index b2e7a75cc8..ba1cd8b679 100644 --- a/src/apps/dashboard/features/plugins/api/usePluginDetails.ts +++ b/src/apps/dashboard/features/plugins/api/usePluginDetails.ts @@ -3,12 +3,14 @@ import { useMemo } from 'react'; import { useApi } from 'hooks/useApi'; -import { useConfigurationPages } from './useConfigurationPages'; -import { usePlugins } from './usePlugins'; +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(); @@ -64,9 +66,25 @@ export const usePluginDetails = () => { 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: packageInfo?.category, + category, description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview, id, imageUrl: imageUrl || packageInfo?.imageUrl || undefined, diff --git a/src/apps/dashboard/features/plugins/constants/categoryLabels.ts b/src/apps/dashboard/features/plugins/constants/categoryLabels.ts index 79867ff1ab..1e301174a1 100644 --- a/src/apps/dashboard/features/plugins/constants/categoryLabels.ts +++ b/src/apps/dashboard/features/plugins/constants/categoryLabels.ts @@ -1,15 +1,14 @@ +import { PluginCategory } from './pluginCategory'; + /** A mapping of category names used by the plugin repository to translation keys. */ -export const CATEGORY_LABELS: Record = { - Administration: 'HeaderAdmin', - General: 'General', - Anime: 'Anime', - // Authentication: 'LabelAuthProvider', // Legacy - Books: 'Books', - // Channel: 'Channels', // Unused? - 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/routes/plugins/catalog.tsx b/src/apps/dashboard/routes/plugins/catalog.tsx index e550963387..cd98de6721 100644 --- a/src/apps/dashboard/routes/plugins/catalog.tsx +++ b/src/apps/dashboard/routes/plugins/catalog.tsx @@ -1,20 +1,22 @@ +import Settings from '@mui/icons-material/Settings'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid2'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; import React, { useCallback, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages'; +import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard'; +import { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory'; +import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; +import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories'; +import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory'; +import Loading from 'components/loading/LoadingComponent'; 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(); @@ -31,7 +33,7 @@ export const Component = () => { }, []); const getCategoryLabel = (category: string) => { - const categoryKey = category.replace(/\s/g, ''); + const categoryKey = category.replace(/\s/g, '') as PluginCategory; if (CATEGORY_LABELS[categoryKey]) { return globalize.translate(CATEGORY_LABELS[categoryKey]); diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index 2341e31ac5..e85ad9e851 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -1,34 +1,42 @@ +import Settings from '@mui/icons-material/Settings'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Divider from '@mui/material/Divider'; import Grid from '@mui/material/Grid2'; +import IconButton from '@mui/material/IconButton/IconButton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import React, { useCallback, useMemo, useState } 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 PluginCard from 'apps/dashboard/features/plugins/components/PluginCard'; +import { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory'; +import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; import Loading from 'components/loading/LoadingComponent'; import Page from 'components/Page'; import globalize from 'lib/globalize'; -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'; +/** + * The list of primary/main categories. + * Any category not in this list will be added to the "other" category. + */ const MAIN_CATEGORIES = [ - 'administration', - 'general', - 'anime', - 'books', - 'livetv', - 'moviesandshows', - 'music', - 'subtitles' + PluginCategory.Administration.toLowerCase(), + PluginCategory.General.toLowerCase(), + PluginCategory.Anime.toLowerCase(), + PluginCategory.Books.toLowerCase(), + PluginCategory.LiveTV.toLowerCase(), + PluginCategory.MoviesAndShows.toLowerCase(), + PluginCategory.Music.toLowerCase(), + PluginCategory.Subtitles.toLowerCase() ]; +/** The installed meta category. */ +const INSTALLED_CATEGORY = 'installed'; + export const Component = () => { const { data: pluginDetails, @@ -47,18 +55,19 @@ export const Component = () => { let filtered = pluginDetails; if (category) { - if (category === 'installed') { + if (category === INSTALLED_CATEGORY) { + // Installed plugins will have a status filtered = filtered.filter(p => p.status); - } else if (category === 'other') { + } else if (category === PluginCategory.Other.toLowerCase()) { filtered = filtered.filter(p => ( - p.category && !MAIN_CATEGORIES.includes(p.category.toLocaleLowerCase()) + p.category && !MAIN_CATEGORIES.includes(p.category.toLowerCase()) )); } else { - filtered = filtered.filter(p => p.category?.toLocaleLowerCase() === category); + filtered = filtered.filter(p => p.category?.toLowerCase() === category); } } return filtered - .filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase())); + .filter(i => i.name?.toLowerCase().includes(searchQuery.toLowerCase())); } else { return []; } @@ -147,21 +156,21 @@ export const Component = () => { /> setCategory('installed')} + onClick={() => setCategory(INSTALLED_CATEGORY)} label={globalize.translate('LabelInstalled')} /> - {Object.keys(CATEGORY_LABELS).map(c => ( + {Object.values(PluginCategory).map(c => ( setCategory(c.toLocaleLowerCase())} - label={globalize.translate(CATEGORY_LABELS[c])} + onClick={() => setCategory(c.toLowerCase())} + label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])} /> ))} From 9fd0fcc175c8b677db7fae9b2e8b7cd06c90e7db Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Mon, 14 Jul 2025 17:28:17 -0400 Subject: [PATCH 03/10] Remove plugin catalog code --- src/apps/dashboard/constants/helpLinks.ts | 5 +- .../plugins/components/PackageCard.tsx | 28 ------ .../plugins/utils/getPackageCategories.ts | 17 ---- .../plugins/utils/getPackagesByCategory.ts | 17 ---- src/apps/dashboard/routes/plugins/catalog.tsx | 98 ------------------- src/strings/en-us.json | 1 - 6 files changed, 1 insertion(+), 165 deletions(-) delete mode 100644 src/apps/dashboard/features/plugins/components/PackageCard.tsx delete mode 100644 src/apps/dashboard/features/plugins/utils/getPackageCategories.ts delete mode 100644 src/apps/dashboard/features/plugins/utils/getPackagesByCategory.ts delete mode 100644 src/apps/dashboard/routes/plugins/catalog.tsx 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/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/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/plugins/catalog.tsx b/src/apps/dashboard/routes/plugins/catalog.tsx deleted file mode 100644 index cd98de6721..0000000000 --- a/src/apps/dashboard/routes/plugins/catalog.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import Settings from '@mui/icons-material/Settings'; -import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid2'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; -import React, { useCallback, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; - -import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages'; -import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard'; -import { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory'; -import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; -import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories'; -import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory'; -import Loading from 'components/loading/LoadingComponent'; -import Page from 'components/Page'; -import globalize from 'lib/globalize'; - -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, '') as PluginCategory; - - 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/strings/en-us.json b/src/strings/en-us.json index 71acbbdd2b..bf999b7353 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1605,7 +1605,6 @@ "TabAccess": "Access", "TabAdvanced": "Advanced", "HeaderBackups": "Backups", - "TabCatalog": "Catalog", "TabDashboard": "Dashboard", "TabLatest": "Recently Added", "TabLogs": "Logs", From 89e07f2f2bcf9a95e6b576fdf77ffbafcc045794 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 16 Jul 2025 13:08:50 -0400 Subject: [PATCH 04/10] Add no plugins messaging --- .../plugins/components/NoPluginResults.tsx | 51 +++++++++++++++++++ src/apps/dashboard/routes/plugins/index.tsx | 30 +++++++---- src/strings/en-us.json | 5 +- 3 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 src/apps/dashboard/features/plugins/components/NoPluginResults.tsx 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/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index e85ad9e851..8429345379 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -12,9 +12,10 @@ 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 { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory'; import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels'; +import { PluginCategory } from 'apps/dashboard/features/plugins/constants/pluginCategory'; import Loading from 'components/loading/LoadingComponent'; import Page from 'components/Page'; import globalize from 'lib/globalize'; @@ -178,15 +179,24 @@ export const Component = () => { - - {filteredPlugins.map(plugin => ( - - - - ))} - + {filteredPlugins.length > 0 ? ( + + {filteredPlugins.map(plugin => ( + + + + ))} + + ) : ( + setCategory(undefined)} + query={searchQuery} + /> + )} )} diff --git a/src/strings/en-us.json b/src/strings/en-us.json index bf999b7353..9977905ca6 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1127,7 +1127,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.", @@ -1160,13 +1159,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.", @@ -1609,7 +1606,6 @@ "TabLatest": "Recently Added", "TabLogs": "Logs", "TabMusic": "Music", - "TabMyPlugins": "My Plugins", "TabNetworking": "Networking", "TabNetworks": "TV Networks", "TabNfoSettings": "NFO Settings", @@ -1700,6 +1696,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", From 325ff3b105cb110eb07e118e49bce31dc6c38013 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 18 Jul 2025 17:02:06 -0400 Subject: [PATCH 05/10] Add plugin status filter independent of categories --- .../plugins/constants/pluginStatusOption.ts | 6 ++ src/apps/dashboard/routes/plugins/index.tsx | 55 +++++++++++++------ src/strings/en-us.json | 1 + 3 files changed, 44 insertions(+), 18 deletions(-) create mode 100644 src/apps/dashboard/features/plugins/constants/pluginStatusOption.ts 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/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index 8429345379..fdcc179633 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -16,6 +16,7 @@ import NoPluginResults from 'apps/dashboard/features/plugins/components/NoPlugin import PluginCard from 'apps/dashboard/features/plugins/components/PluginCard'; 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 globalize from 'lib/globalize'; @@ -35,9 +36,6 @@ const MAIN_CATEGORIES = [ PluginCategory.Subtitles.toLowerCase() ]; -/** The installed meta category. */ -const INSTALLED_CATEGORY = 'installed'; - export const Component = () => { const { data: pluginDetails, @@ -46,6 +44,7 @@ export const Component = () => { } = usePluginDetails(); const [ category, setCategory ] = useState(); const [ searchQuery, setSearchQuery ] = useState(''); + const [ status, setStatus ] = useState(PluginStatusOption.Installed); const onSearchChange = useCallback((event: React.ChangeEvent) => { setSearchQuery(event.target.value); @@ -55,11 +54,14 @@ export const Component = () => { 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 === INSTALLED_CATEGORY) { - // Installed plugins will have a status - filtered = filtered.filter(p => p.status); - } else if (category === PluginCategory.Other.toLowerCase()) { + if (category === PluginCategory.Other.toLowerCase()) { filtered = filtered.filter(p => ( p.category && !MAIN_CATEGORIES.includes(p.category.toLowerCase()) )); @@ -72,7 +74,7 @@ export const Component = () => { } else { return []; } - }, [ category, pluginDetails, searchQuery ]); + }, [ category, pluginDetails, searchQuery, status ]); if (isPending) { return ; @@ -149,6 +151,29 @@ export const Component = () => { overflowX: 'auto' }} > + setStatus(PluginStatusOption.All)} + label={globalize.translate('All')} + /> + + setStatus(PluginStatusOption.Available)} + label={globalize.translate('LabelAvailable')} + /> + + setStatus(PluginStatusOption.Installed)} + label={globalize.translate('LabelInstalled')} + /> + + + { label={globalize.translate('All')} /> - setCategory(INSTALLED_CATEGORY)} - label={globalize.translate('LabelInstalled')} - /> - - - {Object.values(PluginCategory).map(c => ( { setCategory(undefined)} + onViewAll={() => { + setCategory(undefined); + setStatus(PluginStatusOption.All); + }} query={searchQuery} /> )} diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 9977905ca6..fe3c153e8c 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>", From 93821aed8c9400a91bf807ecc8d5289e4853b646 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Mon, 21 Jul 2025 17:44:37 -0400 Subject: [PATCH 06/10] Update view all plugins behavior --- src/apps/dashboard/routes/plugins/index.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index fdcc179633..ddad16117f 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -50,6 +50,11 @@ export const Component = () => { setSearchQuery(event.target.value); }, []); + const onViewAll = useCallback(() => { + if (category) setCategory(undefined); + else setStatus(PluginStatusOption.All); + }, [ category ]); + const filteredPlugins = useMemo(() => { if (pluginDetails) { let filtered = pluginDetails; @@ -207,12 +212,8 @@ export const Component = () => { ) : ( { - setCategory(undefined); - setStatus(PluginStatusOption.All); - }} + isFiltered={!!category || status !== PluginStatusOption.All} + onViewAll={onViewAll} query={searchQuery} /> )} From 4fd2a4041f8ea34cd45ba35b2abdf24b7460d440 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Fri, 25 Jul 2025 17:30:13 -0400 Subject: [PATCH 07/10] Update manage repositories button and use legacy grid --- eslint.config.mjs | 10 ++- src/apps/dashboard/routes/plugins/index.tsx | 88 +++++++++++++-------- src/strings/en-us.json | 1 + 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 03a04c84e4..3414e42e95 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -361,7 +361,15 @@ export default tseslint.config( } } ], - '@typescript-eslint/no-deprecated': 'warn', + '@typescript-eslint/no-deprecated': [ + 'warn', + { + allow: [ + // Allow the deprecated Grid component from mui since JMP does not support CSS gap on some OSs + { from: '@mui/material/Grid', name: 'Grid' } + ] + } + ], '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/prefer-string-starts-ends-with': 'error' } diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index ddad16117f..2cf45a4967 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -1,10 +1,9 @@ -import Settings from '@mui/icons-material/Settings'; import Alert from '@mui/material/Alert'; 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/Grid2'; -import IconButton from '@mui/material/IconButton/IconButton'; +import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import React, { useCallback, useMemo, useState } from 'react'; @@ -20,7 +19,6 @@ import { PluginStatusOption } from 'apps/dashboard/features/plugins/constants/pl import Loading from 'components/loading/LoadingComponent'; import Page from 'components/Page'; import globalize from 'lib/globalize'; - /** * The list of primary/main categories. * Any category not in this list will be added to the "other" category. @@ -101,35 +99,53 @@ export const Component = () => { ) : ( - - - - {globalize.translate('TabPlugins')} - + + {globalize.translate('TabPlugins')} + - - - - + - { value={searchQuery} onChange={onSearchChange} /> - - + + { {filteredPlugins.length > 0 ? ( - + {filteredPlugins.map(plugin => ( - + diff --git a/src/strings/en-us.json b/src/strings/en-us.json index fe3c153e8c..8bfb5b80cf 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -1075,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", From 2912bf50c5346c5d5b14b854a58672d52ec64c0e Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Mon, 28 Jul 2025 10:52:45 -0400 Subject: [PATCH 08/10] Fix eslint config --- eslint.config.mjs | 10 +--------- src/apps/dashboard/routes/plugins/index.tsx | 4 ++++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 3414e42e95..03a04c84e4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -361,15 +361,7 @@ export default tseslint.config( } } ], - '@typescript-eslint/no-deprecated': [ - 'warn', - { - allow: [ - // Allow the deprecated Grid component from mui since JMP does not support CSS gap on some OSs - { from: '@mui/material/Grid', name: 'Grid' } - ] - } - ], + '@typescript-eslint/no-deprecated': 'warn', '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/prefer-string-starts-ends-with': 'error' } diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index 2cf45a4967..0675d03163 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -217,8 +217,12 @@ export const Component = () => { {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 Date: Wed, 30 Jul 2025 12:51:47 -0400 Subject: [PATCH 09/10] Extract search param handling to common hook --- src/apps/stable/routes/search.tsx | 41 +++++++------------------------ src/hooks/useSearchParam.ts | 41 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 src/hooks/useSearchParam.ts 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 => { + const [ searchParams, setSearchParams ] = useSearchParams(); + const urlValue = searchParams.get(param) || ''; + const [ value, setValue ] = useState(urlValue); + const previousValue = usePrevious(value, ''); + + useEffect(() => { + if (value !== previousValue) { + if (value === '' && urlValue !== '') { + // 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; From 1098ca44478265980397454a587741e2f13a8857 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Wed, 30 Jul 2025 14:15:18 -0400 Subject: [PATCH 10/10] Use query parameters for plugin filters --- src/apps/dashboard/routes/plugins/index.tsx | 20 ++++++++++++++------ src/hooks/useSearchParam.ts | 14 ++++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/apps/dashboard/routes/plugins/index.tsx b/src/apps/dashboard/routes/plugins/index.tsx index 0675d03163..b593f2c845 100644 --- a/src/apps/dashboard/routes/plugins/index.tsx +++ b/src/apps/dashboard/routes/plugins/index.tsx @@ -6,7 +6,7 @@ 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, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Link } from 'react-router-dom'; import SearchInput from 'apps/dashboard/components/SearchInput'; @@ -18,7 +18,9 @@ import { PluginCategory } from 'apps/dashboard/features/plugins/constants/plugin 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. @@ -34,23 +36,29 @@ const MAIN_CATEGORIES = [ PluginCategory.Subtitles.toLowerCase() ]; +const CATEGORY_PARAM = 'category'; +const QUERY_PARAM = 'query'; +const STATUS_PARAM = 'status'; + export const Component = () => { const { data: pluginDetails, isError, isPending } = usePluginDetails(); - const [ category, setCategory ] = useState(); - const [ searchQuery, setSearchQuery ] = useState(''); - const [ status, setStatus ] = useState(PluginStatusOption.Installed); + 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(undefined); + if (category) setCategory(''); else setStatus(PluginStatusOption.All); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ category ]); const filteredPlugins = useMemo(() => { @@ -198,7 +206,7 @@ export const Component = () => { setCategory(undefined)} + onClick={() => setCategory('')} label={globalize.translate('All')} /> diff --git a/src/hooks/useSearchParam.ts b/src/hooks/useSearchParam.ts index 6d45c56f5c..83b42ae9e1 100644 --- a/src/hooks/useSearchParam.ts +++ b/src/hooks/useSearchParam.ts @@ -7,15 +7,21 @@ import { usePrevious } from './usePrevious'; * A hook for getting and setting a URL search parameter value that automatically handles updates to/from the URL. * @param param The search parameter name. */ -const useSearchParam: (param: string) => [ string, React.Dispatch> ] = param => { +const useSearchParam: ( + param: string, + defaultValue?: string +) => [ string, React.Dispatch> ] = ( + param, + defaultValue = '' +) => { const [ searchParams, setSearchParams ] = useSearchParams(); - const urlValue = searchParams.get(param) || ''; + const urlValue = searchParams.get(param) || defaultValue; const [ value, setValue ] = useState(urlValue); - const previousValue = usePrevious(value, ''); + const previousValue = usePrevious(value, defaultValue); useEffect(() => { if (value !== previousValue) { - if (value === '' && urlValue !== '') { + if (value === defaultValue && urlValue !== defaultValue) { // The query input has been cleared; remove the url param searchParams.delete(param); setSearchParams(searchParams, { replace: true });