mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Redesign add plugins page
This commit is contained in:
parent
5936ed10ca
commit
e928a2ff95
26 changed files with 1022 additions and 241 deletions
25
src/apps/dashboard/features/plugins/api/pluginInfo.ts
Normal file
25
src/apps/dashboard/features/plugins/api/pluginInfo.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
|
||||
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
|
||||
|
||||
/**
|
||||
* HACK: The Plugins API is returning garbage data in some cases,
|
||||
* so we need to try to find the "best" match if multiple exist.
|
||||
*/
|
||||
export const findBestPluginInfo = (
|
||||
pluginId: string,
|
||||
plugins?: PluginInfo[]
|
||||
) => {
|
||||
if (!plugins) return;
|
||||
// Find all plugin entries with a matching ID
|
||||
const matches = plugins.filter(p => p.Id === pluginId);
|
||||
// Get the first match (or undefined if none)
|
||||
const firstMatch = matches?.[0];
|
||||
|
||||
if (matches.length > 1) {
|
||||
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|
||||
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|
||||
|| firstMatch; // Fallback to the first match
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
};
|
5
src/apps/dashboard/features/plugins/api/queryKey.ts
Normal file
5
src/apps/dashboard/features/plugins/api/queryKey.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export enum QueryKey {
|
||||
ConfigurationPages = 'ConfigurationPages',
|
||||
PackageInfo = 'PackageInfo',
|
||||
Plugins = 'Plugins'
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
|
||||
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchConfigurationPages = async (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchConfigurationPages] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getDashboardApi(api)
|
||||
.getConfigurationPages(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getConfigurationPagesQuery = (
|
||||
api?: Api,
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
|
||||
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const useConfigurationPages = (
|
||||
params?: DashboardApiGetConfigurationPagesRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getConfigurationPagesQuery(api, params));
|
||||
};
|
24
src/apps/dashboard/features/plugins/api/useDisablePlugin.ts
Normal file
24
src/apps/dashboard/features/plugins/api/useDisablePlugin.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useDisablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiDisablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.disablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
24
src/apps/dashboard/features/plugins/api/useEnablePlugin.ts
Normal file
24
src/apps/dashboard/features/plugins/api/useEnablePlugin.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useEnablePlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiEnablePluginRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.enablePlugin(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
27
src/apps/dashboard/features/plugins/api/useInstallPackage.ts
Normal file
27
src/apps/dashboard/features/plugins/api/useInstallPackage.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useInstallPackage = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PackageApiInstallPackageRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPackageApi(api!)
|
||||
.installPackage(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
47
src/apps/dashboard/features/plugins/api/usePackageInfo.ts
Normal file
47
src/apps/dashboard/features/plugins/api/usePackageInfo.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
|
||||
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPackageInfo = async (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPackageInfo] No API instance available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
console.warn('[fetchPackageInfo] Missing request params');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getPackageApi(api)
|
||||
.getPackageInfo(params, options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPackageInfoQuery = (
|
||||
api?: Api,
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => queryOptions({
|
||||
// Don't retry since requests for plugins not available in repos fail
|
||||
retry: false,
|
||||
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
|
||||
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
|
||||
enabled: !!api && !!params?.name
|
||||
});
|
||||
|
||||
export const usePackageInfo = (
|
||||
params?: PackageApiGetPackageInfoRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPackageInfoQuery(api, params));
|
||||
};
|
36
src/apps/dashboard/features/plugins/api/usePlugins.ts
Normal file
36
src/apps/dashboard/features/plugins/api/usePlugins.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
const fetchPlugins = async (
|
||||
api?: Api,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
if (!api) {
|
||||
console.warn('[fetchPlugins] No API instance available');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getPluginsApi(api)
|
||||
.getPlugins(options);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getPluginsQuery = (
|
||||
api?: Api
|
||||
) => queryOptions({
|
||||
queryKey: [ QueryKey.Plugins ],
|
||||
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
|
||||
enabled: !!api
|
||||
});
|
||||
|
||||
export const usePlugins = () => {
|
||||
const { api } = useApi();
|
||||
return useQuery(getPluginsQuery(api));
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
|
||||
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
|
||||
import { QueryKey } from './queryKey';
|
||||
|
||||
export const useUninstallPlugin = () => {
|
||||
const { api } = useApi();
|
||||
return useMutation({
|
||||
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
getPluginsApi(api!)
|
||||
.uninstallPluginByVersion(params)
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.Plugins ]
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QueryKey.ConfigurationPages ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,94 @@
|
|||
import Link from '@mui/material/Link/Link';
|
||||
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import Table from '@mui/material/Table/Table';
|
||||
import TableBody from '@mui/material/TableBody/TableBody';
|
||||
import TableCell from '@mui/material/TableCell/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer/TableContainer';
|
||||
import TableRow from '@mui/material/TableRow/TableRow';
|
||||
import React, { FC } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
|
||||
interface PluginDetailsTableProps extends PaperProps {
|
||||
isPluginLoading: boolean
|
||||
isRepositoryLoading: boolean
|
||||
pluginDetails?: PluginDetails
|
||||
}
|
||||
|
||||
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
|
||||
isPluginLoading,
|
||||
isRepositoryLoading,
|
||||
pluginDetails,
|
||||
...paperProps
|
||||
}) => (
|
||||
<TableContainer component={Paper} {...paperProps}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelStatus')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading && <Skeleton />)
|
||||
|| pluginDetails?.status
|
||||
|| globalize.translate('LabelNotInstalled')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelVersion')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isPluginLoading || !pluginDetails?.version?.version && <Skeleton />)
|
||||
|| pluginDetails?.version?.version
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelDeveloper')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| pluginDetails?.owner
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell variant='head'>
|
||||
{globalize.translate('LabelRepository')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{
|
||||
(isRepositoryLoading && <Skeleton />)
|
||||
|| (pluginDetails?.version?.repositoryUrl && (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={pluginDetails.version.repositoryUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{pluginDetails.version.repositoryName}
|
||||
</Link>
|
||||
))
|
||||
|| globalize.translate('Unknown')
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
export default PluginDetailsTable;
|
|
@ -0,0 +1,34 @@
|
|||
import Paper from '@mui/material/Paper/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton/Skeleton';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
interface PluginImageProps {
|
||||
isLoading: boolean
|
||||
alt?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const PluginImage: FC<PluginImageProps> = ({
|
||||
isLoading,
|
||||
alt,
|
||||
url
|
||||
}) => (
|
||||
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
variant='rectangular'
|
||||
width='100%'
|
||||
height='100%'
|
||||
/>
|
||||
)}
|
||||
{url && (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
width='100%'
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default PluginImage;
|
|
@ -0,0 +1,67 @@
|
|||
import Download from '@mui/icons-material/Download';
|
||||
import DownloadDone from '@mui/icons-material/DownloadDone';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import Accordion from '@mui/material/Accordion/Accordion';
|
||||
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
|
||||
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
|
||||
import Button from '@mui/material/Button/Button';
|
||||
import Stack from '@mui/material/Stack/Stack';
|
||||
import React, { type FC } from 'react';
|
||||
|
||||
import MarkdownBox from 'components/MarkdownBox';
|
||||
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
|
||||
import globalize from 'scripts/globalize';
|
||||
|
||||
import type { PluginDetails } from '../types/PluginDetails';
|
||||
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
interface PluginRevisionsProps {
|
||||
pluginDetails?: PluginDetails,
|
||||
onInstall: (version?: VersionInfo) => () => void
|
||||
}
|
||||
|
||||
const PluginRevisions: FC<PluginRevisionsProps> = ({
|
||||
pluginDetails,
|
||||
onInstall
|
||||
}) => (
|
||||
pluginDetails?.versions?.map(version => (
|
||||
<Accordion key={version.checksum}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMore />}
|
||||
>
|
||||
{version.version}
|
||||
{version.timestamp && (<>
|
||||
—
|
||||
{toLocaleString(parseISO8601Date(version.timestamp))}
|
||||
</>)}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={2}>
|
||||
<MarkdownBox
|
||||
fallback={globalize.translate('LabelNoChangelog')}
|
||||
markdown={version.changelog}
|
||||
/>
|
||||
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
|
||||
<Button
|
||||
disabled
|
||||
startIcon={<DownloadDone />}
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('LabelInstalled')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
startIcon={<Download />}
|
||||
variant='outlined'
|
||||
onClick={onInstall(version)}
|
||||
>
|
||||
{globalize.translate('HeaderInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
);
|
||||
|
||||
export default PluginRevisions;
|
15
src/apps/dashboard/features/plugins/types/PluginDetails.ts
Normal file
15
src/apps/dashboard/features/plugins/types/PluginDetails.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import type { PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
|
||||
|
||||
export interface PluginDetails {
|
||||
canUninstall: boolean
|
||||
description?: string
|
||||
hasConfiguration: boolean
|
||||
id: string
|
||||
imageUrl?: string
|
||||
isEnabled: boolean
|
||||
name?: string
|
||||
owner?: string
|
||||
status?: PluginStatus
|
||||
version?: VersionInfo
|
||||
versions: VersionInfo[]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue