Merge pull request #7017 from thornbill/plugin-unity
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 => (
|
||||
|
||||
@@ -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'],
|
||||
|
||||
119
src/apps/dashboard/features/plugins/api/usePluginDetails.ts
Normal file
119
src/apps/dashboard/features/plugins/api/usePluginDetails.ts
Normal file
@@ -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;
|
||||
@@ -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,96 +0,0 @@
|
||||
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';
|
||||
@@ -1,44 +1,93 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
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 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/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useCallback, useMemo } 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 NoPluginResults from 'apps/dashboard/features/plugins/components/NoPluginResults';
|
||||
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 { 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 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.
|
||||
*/
|
||||
const MAIN_CATEGORIES = [
|
||||
PluginCategory.Administration.toLowerCase(),
|
||||
PluginCategory.General.toLowerCase(),
|
||||
PluginCategory.Anime.toLowerCase(),
|
||||
PluginCategory.Books.toLowerCase(),
|
||||
PluginCategory.LiveTV.toLowerCase(),
|
||||
PluginCategory.MoviesAndShows.toLowerCase(),
|
||||
PluginCategory.Music.toLowerCase(),
|
||||
PluginCategory.Subtitles.toLowerCase()
|
||||
];
|
||||
|
||||
const CATEGORY_PARAM = 'category';
|
||||
const QUERY_PARAM = 'query';
|
||||
const STATUS_PARAM = 'status';
|
||||
|
||||
export const Component = () => {
|
||||
const {
|
||||
data: plugins,
|
||||
isPending,
|
||||
isError
|
||||
} = usePlugins();
|
||||
const {
|
||||
data: configurationPages,
|
||||
isError: isConfigurationPagesError,
|
||||
isPending: isConfigurationPagesPending
|
||||
} = useConfigurationPages();
|
||||
const [ searchQuery, setSearchQuery ] = useState('');
|
||||
data: pluginDetails,
|
||||
isError,
|
||||
isPending
|
||||
} = usePluginDetails();
|
||||
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<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onViewAll = useCallback(() => {
|
||||
if (category) setCategory('');
|
||||
else setStatus(PluginStatusOption.All);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ category ]);
|
||||
|
||||
const filteredPlugins = useMemo(() => {
|
||||
if (plugins) {
|
||||
return plugins.filter(i => i.Name?.toLocaleLowerCase().includes(searchQuery.toLocaleLowerCase()));
|
||||
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 === PluginCategory.Other.toLowerCase()) {
|
||||
filtered = filtered.filter(p => (
|
||||
p.category && !MAIN_CATEGORIES.includes(p.category.toLowerCase())
|
||||
));
|
||||
} else {
|
||||
filtered = filtered.filter(p => p.category?.toLowerCase() === category);
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
.filter(i => i.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [ plugins, searchQuery ]);
|
||||
}, [ category, pluginDetails, searchQuery, status ]);
|
||||
|
||||
if (isPending || isConfigurationPagesPending) {
|
||||
if (isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -49,31 +98,161 @@ 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}>
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
flexWrap: {
|
||||
xs: 'wrap',
|
||||
sm: 'nowrap'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='h1'
|
||||
component='span'
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('TabPlugins')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<Button
|
||||
component={Link}
|
||||
to='/dashboard/plugins/repositories'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
marginLeft: 2
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ManageRepositories')}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
marginTop: {
|
||||
xs: 2,
|
||||
sm: 0
|
||||
},
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
sm: 2
|
||||
},
|
||||
width: {
|
||||
xs: '100%',
|
||||
sm: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchInput
|
||||
label={globalize.translate('Search')}
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<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 }}>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
configurationPage={findBestConfigurationPage(configurationPages, plugin.Id || '')}
|
||||
/>
|
||||
</Grid>
|
||||
<Stack
|
||||
direction='row'
|
||||
spacing={1}
|
||||
sx={{
|
||||
marginLeft: '-1rem',
|
||||
marginRight: '-1rem',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
paddingBottom: {
|
||||
xs: 1,
|
||||
md: 0.5
|
||||
},
|
||||
overflowX: 'auto'
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
color={status === PluginStatusOption.All ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.All)}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Available ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Available)}
|
||||
label={globalize.translate('LabelAvailable')}
|
||||
/>
|
||||
|
||||
<Chip
|
||||
color={status === PluginStatusOption.Installed ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setStatus(PluginStatusOption.Installed)}
|
||||
label={globalize.translate('LabelInstalled')}
|
||||
/>
|
||||
|
||||
<Divider orientation='vertical' flexItem />
|
||||
|
||||
<Chip
|
||||
color={!category ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory('')}
|
||||
label={globalize.translate('All')}
|
||||
/>
|
||||
|
||||
{Object.values(PluginCategory).map(c => (
|
||||
<Chip
|
||||
key={c}
|
||||
color={category === c.toLowerCase() ? 'primary' : undefined}
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => setCategory(c.toLowerCase())}
|
||||
label={globalize.translate(CATEGORY_LABELS[c as PluginCategory])}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{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
|
||||
<Grid container spacing={2}>
|
||||
{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
|
||||
<Grid
|
||||
key={plugin.id}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={4}
|
||||
lg={3}
|
||||
xl={2}
|
||||
>
|
||||
<PluginCard
|
||||
plugin={plugin}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<NoPluginResults
|
||||
isFiltered={!!category || status !== PluginStatusOption.All}
|
||||
onViewAll={onViewAll}
|
||||
query={searchQuery}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -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' ]} />
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<Page
|
||||
id='searchPage'
|
||||
|
||||
47
src/hooks/useSearchParam.ts
Normal file
47
src/hooks/useSearchParam.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
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,
|
||||
defaultValue?: string
|
||||
) => [ string, React.Dispatch<React.SetStateAction<string>> ] = (
|
||||
param,
|
||||
defaultValue = ''
|
||||
) => {
|
||||
const [ searchParams, setSearchParams ] = useSearchParams();
|
||||
const urlValue = searchParams.get(param) || defaultValue;
|
||||
const [ value, setValue ] = useState(urlValue);
|
||||
const previousValue = usePrevious(value, defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== previousValue) {
|
||||
if (value === defaultValue && urlValue !== defaultValue) {
|
||||
// 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;
|
||||
@@ -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: <code>http://example.com/<b><baseurl></b></code>",
|
||||
@@ -640,6 +641,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",
|
||||
@@ -1073,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",
|
||||
@@ -1126,7 +1129,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.",
|
||||
@@ -1159,13 +1161,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.",
|
||||
@@ -1604,12 +1604,10 @@
|
||||
"TabAccess": "Access",
|
||||
"TabAdvanced": "Advanced",
|
||||
"HeaderBackups": "Backups",
|
||||
"TabCatalog": "Catalog",
|
||||
"TabDashboard": "Dashboard",
|
||||
"TabLatest": "Recently Added",
|
||||
"TabLogs": "Logs",
|
||||
"TabMusic": "Music",
|
||||
"TabMyPlugins": "My Plugins",
|
||||
"TabNetworking": "Networking",
|
||||
"TabNetworks": "TV Networks",
|
||||
"TabNfoSettings": "NFO Settings",
|
||||
@@ -1700,6 +1698,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",
|
||||
|
||||
Reference in New Issue
Block a user