Add unified plugin page
This commit is contained in:
@@ -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 (
|
||||
<Card
|
||||
sx={{
|
||||
|
||||
70
src/apps/dashboard/components/SearchInput.tsx
Normal file
70
src/apps/dashboard/components/SearchInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import Search from '@mui/icons-material/Search';
|
||||
import InputBase, { type InputBaseProps } from '@mui/material/InputBase';
|
||||
import { alpha, styled } from '@mui/material/styles';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
const SearchContainer = styled('div')(({ theme }) => ({
|
||||
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<SearchInputProps> = ({
|
||||
label,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<SearchContainer>
|
||||
<SearchIconWrapper>
|
||||
<Search />
|
||||
</SearchIconWrapper>
|
||||
<StyledInputBase
|
||||
placeholder={label}
|
||||
inputProps={{
|
||||
'aria-label': label,
|
||||
...props.inputProps
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</SearchContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins'
|
||||
includePaths={[ '/configurationpage' ]}
|
||||
includePaths={[
|
||||
'/configurationpage',
|
||||
'/dashboard/plugins/repositories'
|
||||
]}
|
||||
excludePaths={pagesInfo?.map(p => `/${Dashboard.getPluginUrl(p.Name)}`)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Extension />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabMyPlugins')} />
|
||||
</ListItemLink>
|
||||
|
||||
<ListItemLink
|
||||
to='/dashboard/plugins/catalog'
|
||||
includePaths={[ '/dashboard/plugins/repositories' ]}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Public />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={globalize.translate('TabCatalog')} />
|
||||
<ListItemText primary={globalize.translate('TabPlugins')} />
|
||||
</ListItemLink>
|
||||
|
||||
{pagesInfo?.map(pageInfo => (
|
||||
|
||||
101
src/apps/dashboard/features/plugins/api/usePluginDetails.ts
Normal file
101
src/apps/dashboard/features/plugins/api/usePluginDetails.ts
Normal file
@@ -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<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`);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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,13 +1,13 @@
|
||||
/** 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',
|
||||
Anime: 'Anime',
|
||||
// Authentication: 'LabelAuthProvider', // Legacy
|
||||
Books: 'Books',
|
||||
// Channel: 'Channels', // Unused?
|
||||
LiveTV: 'LiveTV',
|
||||
Metadata: 'LabelMetadata', // Legacy
|
||||
// Metadata: 'LabelMetadata', // Legacy
|
||||
MoviesAndShows: 'MoviesAndShows',
|
||||
Music: 'TabMusic',
|
||||
Subtitles: 'Subtitles',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
category?: string
|
||||
description?: string
|
||||
id: string
|
||||
imageUrl?: string
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string>();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
|
||||
const onSearchChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
@@ -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 <Loading />;
|
||||
}
|
||||
|
||||
@@ -49,27 +75,105 @@ export const Component = () => {
|
||||
className='type-interior mainAnimatedPage'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
{isError || isConfigurationPagesError ? (
|
||||
<Alert severity='error'>{globalize.translate('PluginsLoadError')}</Alert>
|
||||
{isError ? (
|
||||
<Alert
|
||||
severity='error'
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate('PluginsLoadError')}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
<Typography variant='h1'>
|
||||
{globalize.translate('TabMyPlugins')}
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
>
|
||||
<Grid size={{ xs: 12, sm: 8 }}>
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
marginRight: 2,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
size={{ xs: 12, sm: 4 }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end'
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(undefined)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={category === 'installed' ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('installed')}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
{Object.keys(CATEGORY_LABELS).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLocaleLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLocaleLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
|
||||
{filteredPlugins.map(plugin => (
|
||||
<Grid key={plugin.Id} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<Grid key={plugin.id} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
configurationPage={findBestConfigurationPage(configurationPages, plugin.Id || '')}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -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 = () => {
|
||||
<Container className='content-primary'>
|
||||
|
||||
{alertMessages.map(({ severity = 'error', messageKey }) => (
|
||||
<Alert key={messageKey} severity={severity}>
|
||||
<Alert
|
||||
key={messageKey}
|
||||
severity={severity}
|
||||
sx={{ marginBottom: 2 }}
|
||||
>
|
||||
{globalize.translate(messageKey)}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
@@ -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: <Navigate replace to='/dashboard/plugins' />
|
||||
}
|
||||
],
|
||||
errorElement: <ErrorBoundary pageClasses={[ 'type-interior' ]} />
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user