Merge pull request #6951 from viown/react-plugins-available

Migrate plugins catalog to React
This commit is contained in:
Bill Thornton
2025-06-27 11:54:00 -04:00
committed by GitHub
14 changed files with 233 additions and 206 deletions

View File

@@ -9,6 +9,7 @@ import MoreVertIcon from '@mui/icons-material/MoreVert';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import CardActionArea from '@mui/material/CardActionArea';
import Stack from '@mui/material/Stack';
import { Link, To } from 'react-router-dom';
interface IProps {
title?: string;
@@ -16,13 +17,14 @@ interface IProps {
text?: string;
image?: string | null;
icon?: React.ReactNode;
to?: To;
onClick?: () => void;
action?: boolean;
actionRef?: React.MutableRefObject<HTMLButtonElement | null>;
onActionClick?: () => void;
};
const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, actionRef, onActionClick }: IProps) => {
const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: IProps) => {
return (
<Card
sx={{
@@ -31,11 +33,18 @@ const BaseCard = ({ title, secondaryTitle, text, image, icon, onClick, action, a
height: 240
}}
>
<CardActionArea onClick={onClick} sx={{
display: 'flex',
flexGrow: 1,
alignItems: 'stretch'
}}>
<CardActionArea
{...(to && {
component: Link,
to: to
})}
onClick={onClick}
sx={{
display: 'flex',
flexGrow: 1,
alignItems: 'stretch'
}}
>
{image ? (
<CardMedia
sx={{ flexGrow: 1 }}

View File

@@ -1,17 +0,0 @@
<div id="pluginCatalogPage" data-role="page" class="page type-interior pluginConfigurationPage fullWidthContent" data-title="${TabCatalog}">
<div>
<div class="content-primary">
<div class="sectionTitleContainer flex align-items-center">
<h2 class="sectionTitle">${TabCatalog}</h2>
<a is="emby-linkbutton" class="fab" href="#/dashboard/plugins/repositories" style="margin-left:1em;" title="${Settings}">
<span class="material-icons settings" aria-hidden="true"></span>
</a>
</div>
<div class="inputContainer">
<input id="txtSearchPlugins" name="txtSearchPlugins" type="text" is="emby-input" label="${Search}" />
</div>
<div id="noPlugins" class="hide">${MessageNoAvailablePlugins}</div>
<div id="pluginTiles" style="text-align:left;"></div>
</div>
</div>
</div>

View File

@@ -1,163 +0,0 @@
import escapeHTML from 'escape-html';
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
import loading from 'components/loading/loading';
import globalize from 'lib/globalize';
import 'components/cardbuilder/card.scss';
import 'elements/emby-button/emby-button';
import 'elements/emby-checkbox/emby-checkbox';
import 'elements/emby-select/emby-select';
function reloadList(page) {
loading.show();
const promise1 = ApiClient.getAvailablePlugins();
const promise2 = ApiClient.getInstalledPlugins();
Promise.all([promise1, promise2]).then(function (responses) {
populateList({
catalogElement: page.querySelector('#pluginTiles'),
noItemsElement: page.querySelector('#noPlugins'),
availablePlugins: responses[0],
installedPlugins: responses[1]
});
});
}
function getHeaderText(category) {
const categoryKey = category.replaceAll(' ', '');
if (CATEGORY_LABELS[categoryKey]) {
return globalize.translate(CATEGORY_LABELS[categoryKey]);
}
console.warn('[AvailablePlugins] unmapped category label', category);
return category;
}
function populateList(options) {
const availablePlugins = options.availablePlugins;
const installedPlugins = options.installedPlugins;
availablePlugins.forEach(function (plugin, index, array) {
plugin.category = plugin.category || 'Other';
plugin.categoryDisplayName = getHeaderText(plugin.category);
array[index] = plugin;
});
availablePlugins.sort(function (a, b) {
if (a.category > b.category) {
return 1;
} else if (b.category > a.category) {
return -1;
}
if (a.name > b.name) {
return 1;
} else if (b.name > a.name) {
return -1;
}
return 0;
});
let currentCategory = null;
let html = '';
for (const plugin of availablePlugins) {
const category = plugin.categoryDisplayName;
if (category != currentCategory) {
if (currentCategory) {
html += '</div>';
html += '</div>';
}
html += '<div class="verticalSection">';
html += '<h2 class="sectionTitle sectionTitle-cards">' + escapeHTML(category) + '</h2>';
html += '<div class="itemsContainer vertical-wrap">';
currentCategory = category;
}
html += getPluginHtml(plugin, options, installedPlugins);
}
html += '</div>';
html += '</div>';
if (!availablePlugins.length && options.noItemsElement) {
options.noItemsElement.classList.remove('hide');
}
const searchBar = document.getElementById('txtSearchPlugins');
if (searchBar) {
searchBar.addEventListener('input', () => onSearchBarType(searchBar));
}
options.catalogElement.innerHTML = html;
loading.hide();
}
function onSearchBarType(searchBar) {
const filter = searchBar.value.toLowerCase();
for (const header of document.querySelectorAll('div .verticalSection')) {
// keep track of shown cards after each search
let shown = 0;
for (const card of header.querySelectorAll('div .card')) {
if (filter && filter != '' && !card.textContent.toLowerCase().includes(filter)) {
card.style.display = 'none';
} else {
card.style.display = 'unset';
shown++;
}
}
// hide title if no cards are shown
if (shown <= 0) {
header.style.display = 'none';
} else {
header.style.display = 'unset';
}
}
}
function getPluginHtml(plugin, options, installedPlugins) {
let html = '';
let href = plugin.externalUrl ? plugin.externalUrl :
`#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`;
if (options.context) {
href += '&context=' + options.context;
}
const target = plugin.externalUrl ? ' target="_blank"' : '';
html += "<div class='card backdropCard'>";
html += '<div class="cardBox visualCardBox">';
html += '<div class="cardScalable visualCardBox-cardScalable">';
html += '<div class="cardPadder cardPadder-backdrop"></div>';
html += '<div class="cardContent">';
html += `<a class="cardImageContainer" is="emby-linkbutton" style="margin:0;padding:0" href="${href}" ${target}>`;
if (plugin.imageUrl) {
html += `<img src="${escapeHTML(plugin.imageUrl)}" style="width:100%" />`;
} else {
html += `<div class="cardImage flex align-items-center justify-content-center ${getDefaultBackgroundClass()}">`;
html += '<span class="cardImageIcon material-icons extension" aria-hidden="true"></span>';
html += '</div>';
}
html += '</a>';
html += '</div>';
html += '</div>';
html += '<div class="cardFooter">';
html += "<div class='cardText'>";
html += escapeHTML(plugin.name);
html += '</div>';
const installedPlugin = installedPlugins.find(installed => installed.Id === plugin.guid);
html += "<div class='cardText cardText-secondary'>";
html += installedPlugin ? globalize.translate('LabelVersionInstalled', installedPlugin.Version) : '&nbsp;';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
export default function (view) {
view.addEventListener('viewshow', function () {
reloadList(this);
});
}

View File

@@ -1,6 +1,6 @@
export enum QueryKey {
ConfigurationPages = 'ConfigurationPages',
PackageInfo = 'PackageInfo',
Packages = 'Packages',
Plugins = 'Plugins',
Repositories = 'Repositories'
}

View File

@@ -7,12 +7,24 @@ import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
import { queryClient } from 'utils/query/queryClient';
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
const fetchPackageInfo = async (
api: Api,
params: PackageApiGetPackageInfoRequest,
options?: AxiosRequestConfig
) => {
const packagesData = queryClient.getQueryData([ QueryKey.Packages ]) as PackageInfo[];
if (packagesData && params.assemblyGuid) {
// Use cached query to avoid re-fetching
const pkg = packagesData.find(v => v.guid === params.assemblyGuid);
if (pkg) {
return pkg;
}
}
const response = await getPackageApi(api)
.getPackageInfo(params, options);
return response.data;
@@ -24,7 +36,7 @@ const getPackageInfoQuery = (
) => queryOptions({
// Don't retry since requests for plugins not available in repos fail
retry: false,
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
queryKey: [ QueryKey.Packages, params?.name, params?.assemblyGuid ],
queryFn: ({ signal }) => fetchPackageInfo(api!, params!, { signal }),
enabled: !!params && !!api && !!params.name
});

View File

@@ -0,0 +1,32 @@
import type { Api } from '@jellyfin/sdk';
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchPackages = async (
api: Api,
options?: AxiosRequestConfig
) => {
const response = await getPackageApi(api)
.getPackages(options);
return response.data;
};
const getPackagesQuery = (
api?: Api
) => queryOptions({
queryKey: [ QueryKey.Packages ],
queryFn: ({ signal }) => fetchPackages(api!, { signal }),
enabled: !!api,
staleTime: 15 * 60 * 1000 // 15 minutes
});
export const usePackages = () => {
const { api } = useApi();
return useQuery(getPackagesQuery(api));
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import type { PackageInfo } from '@jellyfin/sdk/lib/generated-client/models/package-info';
import ExtensionIcon from '@mui/icons-material/Extension';
import BaseCard from 'apps/dashboard/components/BaseCard';
import { useLocation } from 'react-router-dom';
type IProps = {
pkg: PackageInfo;
};
const PackageCard = ({ pkg }: IProps) => {
const location = useLocation();
return (
<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;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useApi } from 'hooks/useApi';
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
@@ -37,15 +37,17 @@ const PluginCard = ({ plugin, configurationPage }: IProps) => {
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
const { api } = useApi();
const navigateToPluginSettings = useCallback(() => {
if (configurationPage) {
navigate({
pathname: '/configurationpage',
search: `?name=${encodeURIComponent(configurationPage.Name || '')}`,
hash: location.hash
});
const pluginPage = useMemo(() => (
{
pathname: '/configurationpage',
search: `?name=${encodeURIComponent(configurationPage?.Name || '')}`,
hash: location.hash
}
}, [ navigate, location, configurationPage ]);
), [ location, configurationPage ]);
const navigateToPluginSettings = useCallback(() => {
navigate(pluginPage);
}, [ navigate, pluginPage ]);
const onEnablePlugin = useCallback(() => {
if (plugin.Id && plugin.Version) {
@@ -105,12 +107,12 @@ const PluginCard = ({ plugin, configurationPage }: IProps) => {
<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}
onClick={navigateToPluginSettings}
onActionClick={onActionClick}
/>
<Menu

View File

@@ -1,5 +1,5 @@
/** A mapping of category names used by the plugin repository to translation keys. */
export const CATEGORY_LABELS = {
export const CATEGORY_LABELS: Record<string, string> = {
Administration: 'HeaderAdmin',
Anime: 'Anime',
Authentication: 'LabelAuthProvider', // Legacy

View File

@@ -0,0 +1,17 @@
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;

View File

@@ -0,0 +1,17 @@
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;

View File

@@ -20,6 +20,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'playback/trickplay', type: AppType.Dashboard },
{ path: 'plugins', type: AppType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AppType.Dashboard },
{ path: 'plugins/catalog', type: AppType.Dashboard },
{ path: 'plugins/repositories', type: AppType.Dashboard },
{ path: 'tasks', type: AppType.Dashboard },
{ path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard },

View File

@@ -23,13 +23,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'library',
view: 'library.html'
}
}, {
path: 'plugins/catalog',
pageProps: {
appType: AppType.Dashboard,
controller: 'plugins/available/index',
view: 'plugins/available/index.html'
}
}, {
path: 'livetv/guide',
pageProps: {

View File

@@ -0,0 +1,96 @@
import React, { useCallback, useMemo, useState } from 'react';
import Page from 'components/Page';
import globalize from 'lib/globalize';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { usePackages } from 'apps/dashboard/features/plugins/api/usePackages';
import Loading from 'components/loading/LoadingComponent';
import getPackageCategories from 'apps/dashboard/features/plugins/utils/getPackageCategories';
import Stack from '@mui/material/Stack';
import getPackagesByCategory from 'apps/dashboard/features/plugins/utils/getPackagesByCategory';
import PackageCard from 'apps/dashboard/features/plugins/components/PackageCard';
import Grid from '@mui/material/Grid2';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import Settings from '@mui/icons-material/Settings';
import { Link } from 'react-router-dom';
import { CATEGORY_LABELS } from 'apps/dashboard/features/plugins/constants/categoryLabels';
export const Component = () => {
const { data: packages, isPending: isPackagesPending } = usePackages();
const [ searchQuery, setSearchQuery ] = useState('');
const filteredPackages = useMemo(() => {
return packages?.filter(i => i.name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
}, [ packages, searchQuery ]);
const packageCategories = getPackageCategories(filteredPackages);
const updateSearchQuery = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
}, []);
const getCategoryLabel = (category: string) => {
const categoryKey = category.replace(/\s/g, '');
if (CATEGORY_LABELS[categoryKey]) {
return globalize.translate(CATEGORY_LABELS[categoryKey]);
}
console.warn('[AvailablePlugins] unmapped category label', category);
return category;
};
if (isPackagesPending) {
return <Loading />;
}
return (
<Page
id='pluginCatalogPage'
className='mainAnimatedPage type-interior'
title={globalize.translate('TabCatalog')}
>
<Box className='content-primary'>
<Stack spacing={3}>
<Stack direction='row' gap={1}>
<Typography variant='h1'>{globalize.translate('TabCatalog')}</Typography>
<IconButton
component={Link}
to='/dashboard/plugins/repositories'
sx={{
backgroundColor: 'background.paper'
}}
>
<Settings />
</IconButton>
</Stack>
<TextField
label={globalize.translate('Search')}
value={searchQuery}
onChange={updateSearchQuery}
/>
{packageCategories.map(category => (
<Stack key={category} spacing={2}>
<Typography variant='h2'>{getCategoryLabel(category)}</Typography>
<Grid container spacing={2} columns={{ xs: 1, sm: 4, md: 9, lg: 8, xl: 10 }}>
{getPackagesByCategory(filteredPackages, category).map(pkg => (
<Grid key={pkg.guid} size={{ xs: 1, sm: 2, md: 3, lg: 2 }}>
<PackageCard
pkg={pkg}
/>
</Grid>
))}
</Grid>
</Stack>
))}
</Stack>
</Box>
</Page>
);
};
Component.displayName = 'PluginsCatalogPage';