Merge pull request #7017 from thornbill/plugin-unity
Add unified plugin page
This commit is contained in:
@@ -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<PluginDetails[]>(() => {
|
||||
if (!isPackagesPending && !isPluginsPending) {
|
||||
const pluginIds = new Set<string>();
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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<NoPluginResultsProps> = ({
|
||||
isFiltered,
|
||||
onViewAll,
|
||||
query
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component='div'
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
{
|
||||
query ?
|
||||
globalize.translate('SearchResultsEmpty', query) :
|
||||
globalize.translate('NoSubtitleSearchResultsFound')
|
||||
}
|
||||
</Typography>
|
||||
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant='text'
|
||||
onClick={onViewAll}
|
||||
>
|
||||
{globalize.translate('ViewAllPlugins')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoPluginResults;
|
||||
@@ -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 (
|
||||
<BaseCard
|
||||
title={pkg.name}
|
||||
image={pkg.imageUrl}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
to={{
|
||||
pathname: `/dashboard/plugins/${pkg.guid}`,
|
||||
search: `?name=${encodeURIComponent(pkg.name || '')}`,
|
||||
hash: location.hash
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackageCard;
|
||||
@@ -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<HTMLButtonElement | null>(null);
|
||||
const enablePlugin = useEnablePlugin();
|
||||
const disablePlugin = useDisablePlugin();
|
||||
const uninstallPlugin = useUninstallPlugin();
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(null);
|
||||
const [ isMenuOpen, setIsMenuOpen ] = useState(false);
|
||||
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
|
||||
const { api } = useApi();
|
||||
|
||||
const 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 (
|
||||
<>
|
||||
<BaseCard
|
||||
title={plugin.Name}
|
||||
secondaryTitle={plugin.Version}
|
||||
to={pluginPage}
|
||||
text={`${globalize.translate('LabelStatus')} ${plugin.Status}`}
|
||||
image={plugin.HasImage ? api?.getUri(`/Plugins/${plugin.Id}/${plugin.Version}/Image`) : null}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
{configurationPage && (
|
||||
<MenuItem onClick={navigateToPluginSettings}>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('Settings')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(plugin.CanUninstall && plugin.Status === PluginStatus.Active) && (
|
||||
<MenuItem onClick={onDisablePlugin}>
|
||||
<ListItemIcon>
|
||||
<BlockIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('DisablePlugin')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(plugin.CanUninstall && plugin.Status === PluginStatus.Disabled) && (
|
||||
<MenuItem onClick={onEnablePlugin}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleOutlineIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('EnablePlugin')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{plugin.CanUninstall && (
|
||||
<MenuItem onClick={showUninstallConfirmDialog}>
|
||||
<ListItemIcon>
|
||||
<Delete />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonUninstall')}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<ConfirmDialog
|
||||
open={isUninstallConfirmOpen}
|
||||
title={globalize.translate('HeaderUninstallPlugin')}
|
||||
text={globalize.translate('UninstallPluginConfirmation', plugin.Name || '')}
|
||||
onCancel={onCloseUninstallConfirmDialog}
|
||||
onConfirm={onUninstall}
|
||||
confirmButtonColor='error'
|
||||
confirmButtonText={globalize.translate('ButtonUninstall')}
|
||||
/>
|
||||
</>
|
||||
<BaseCard
|
||||
title={plugin.name}
|
||||
to={pluginPage}
|
||||
text={[plugin.version?.VersionNumber, plugin.status].filter(t => t).join(' ')}
|
||||
image={plugin.imageUrl}
|
||||
icon={<ExtensionIcon sx={{ width: 80, height: 80 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| (pluginDetails?.status && pluginDetails?.canUninstall === false
|
||||
&& globalize.translate('LabelBundled')
|
||||
)
|
||||
|| (pluginDetails?.version?.repositoryUrl && (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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, string> = {
|
||||
[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'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Options for filtering plugins based on the installation status. */
|
||||
export enum PluginStatusOption {
|
||||
All = 'All',
|
||||
Available = 'Available',
|
||||
Installed = 'Installed'
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
category?: string
|
||||
description?: string
|
||||
id: string
|
||||
imageUrl?: string
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user