Original file line number Diff line number Diff line change
@@ -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 && <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;
34 changes: 34 additions & 0 deletions src/apps/dashboard/features/plugins/components/PluginImage.tsx
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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 && (<>
&nbsp;&mdash;&nbsp;
{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 changes: 15 additions & 0 deletions src/apps/dashboard/features/plugins/types/PluginDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';

export interface PluginDetails {
canUninstall: boolean
description?: string
id: string
imageUrl?: string
isEnabled: boolean
name?: string
owner?: string
configurationPage?: ConfigurationPageInfo
status?: PluginStatus
version?: VersionInfo
versions: VersionInfo[]
}
5 changes: 3 additions & 2 deletions src/apps/dashboard/routes/_asyncRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';

export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AsyncRouteType.Dashboard },
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
{ path: 'users', type: AsyncRouteType.Dashboard },
{ path: 'users/access', type: AsyncRouteType.Dashboard },
{ path: 'users/add', type: AsyncRouteType.Dashboard },
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
{ path: 'users/password', type: AsyncRouteType.Dashboard },
{ path: 'users/profile', type: AsyncRouteType.Dashboard },
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
{ path: 'users/profile', type: AsyncRouteType.Dashboard }
];
6 changes: 0 additions & 6 deletions src/apps/dashboard/routes/_legacyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
}
}, {
path: 'plugins/add',
pageProps: {
controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html'
}
}, {
path: 'libraries',
pageProps: {
Expand Down
1 change: 0 additions & 1 deletion src/apps/dashboard/routes/_redirects.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Redirect } from 'components/router/Redirect';

export const REDIRECTS: Redirect[] = [
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
{ from: 'apikeys.html', to: '/dashboard/keys' },
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
{ from: 'dashboard.html', to: '/dashboard' },
Expand Down
443 changes: 443 additions & 0 deletions src/apps/dashboard/routes/plugins/plugin.tsx

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Button from '@mui/material/Button/Button';
import Dialog, { type DialogProps } from '@mui/material/Dialog/Dialog';
import DialogActions from '@mui/material/DialogActions/DialogActions';
import DialogContent from '@mui/material/DialogContent/DialogContent';
import DialogContentText from '@mui/material/DialogContentText/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle/DialogTitle';
import React, { type FC } from 'react';

import globalize from 'scripts/globalize';

interface ConfirmDialogProps extends DialogProps {
confirmButtonColor?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning'
confirmButtonText?: string
title: string
text: string
onCancel: () => void
onConfirm: () => void
}

/** Convenience wrapper for a simple MUI Dialog component for displaying a prompt that needs confirmation. */
const ConfirmDialog: FC<ConfirmDialogProps> = ({
confirmButtonColor = 'primary',
confirmButtonText,
title,
text,
onCancel,
onConfirm,
...dialogProps
}) => (
<Dialog {...dialogProps}>
<DialogTitle>
{title}
</DialogTitle>
<DialogContent>
<DialogContentText>
{text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
variant='text'
onClick={onCancel}
>
{globalize.translate('ButtonCancel')}
</Button>
<Button
color={confirmButtonColor}
onClick={onConfirm}
>
{confirmButtonText || globalize.translate('ButtonOk')}
</Button>
</DialogActions>
</Dialog>
);

export default ConfirmDialog;
37 changes: 37 additions & 0 deletions src/components/MarkdownBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Box from '@mui/material/Box/Box';
import DOMPurify from 'dompurify';
import markdownIt from 'markdown-it';
import React, { type FC } from 'react';

interface MarkdownBoxProps {
markdown?: string | null
fallback?: string
}

/** A component to render Markdown content within a MUI Box component. */
const MarkdownBox: FC<MarkdownBoxProps> = ({
markdown,
fallback
}) => (
<Box
dangerouslySetInnerHTML={
markdown ?
{ __html: DOMPurify.sanitize(markdownIt({ html: true }).render(markdown)) } :
undefined
}
sx={{
'> :first-child /* emotion-disable-server-rendering-unsafe-selector-warning-please-do-not-use-this-the-warning-exists-for-a-reason */': {
marginTop: 0,
paddingTop: 0
},
'> :last-child': {
marginBottom: 0,
paddingBottom: 0
}
}}
>
{markdown ? undefined : fallback}
</Box>
);

export default MarkdownBox;
51 changes: 0 additions & 51 deletions src/controllers/dashboard/plugins/add/index.html

This file was deleted.

165 changes: 0 additions & 165 deletions src/controllers/dashboard/plugins/add/index.js

This file was deleted.

3 changes: 2 additions & 1 deletion src/controllers/dashboard/plugins/available/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ function onSearchBarType(searchBar) {

function getPluginHtml(plugin, options, installedPlugins) {
let html = '';
let href = plugin.externalUrl ? plugin.externalUrl : '#/dashboard/plugins/add?name=' + encodeURIComponent(plugin.name) + '&guid=' + plugin.guid;
let href = plugin.externalUrl ? plugin.externalUrl :
`#/dashboard/plugins/${plugin.guid}?name=${encodeURIComponent(plugin.name)}`;

if (options.context) {
href += '&context=' + options.context;
Expand Down
17 changes: 11 additions & 6 deletions src/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,6 @@
"HeaderPlaybackError": "Playback Error",
"HeaderPlayOn": "Play On",
"HeaderPleaseSignIn": "Please sign in",
"HeaderPluginInstallation": "Plugin Installation",
"HeaderPortRanges": "Firewall and Proxy Settings",
"HeaderPreferredMetadataLanguage": "Preferred Metadata Language",
"HeaderRecentlyPlayed": "Recently Played",
Expand Down Expand Up @@ -661,6 +660,7 @@
"LabelEnableIP6Help": "Enable IPv6 functionality.",
"LabelEnableLUFSScan": "Enable LUFS scan",
"LabelEnableLUFSScanHelp": "Clients can normalize audio playback to get equal loudness across tracks. This will make library scans longer and take more resources.",
"LabelEnablePlugin": "Enable plugin",
"LabelEnableRealtimeMonitor": "Enable real time monitoring",
"LabelEnableRealtimeMonitorHelp": "Changes to files will be processed immediately on supported file systems.",
"LabelEncoderPreset": "Encoding preset",
Expand Down Expand Up @@ -694,6 +694,7 @@
"LabelImageFetchersHelp": "Enable and rank your preferred image fetchers in order of priority.",
"LabelImageType": "Image type",
"LabelImportOnlyFavoriteChannels": "Restrict to channels marked as favorite",
"LabelInstalled": "Installed",
"LabelInternetQuality": "Internet quality",
"LabelIsForced": "Forced",
"LabelKeepUpTo": "Keep up to",
Expand Down Expand Up @@ -762,6 +763,8 @@
"LabelNewPassword": "New password",
"LabelNewPasswordConfirm": "New password confirm",
"LabelNewsCategories": "News categories",
"LabelNoChangelog": "No changelog provided for this release.",
"LabelNotInstalled": "Not installed",
"LabelNumber": "Number",
"LabelNumberOfGuideDays": "Number of days of guide data to download",
"LabelNumberOfGuideDaysHelp": "Downloading more days worth of guide data provides the ability to schedule out further in advance and view more listings, but it will also take longer to download. Auto will pick based on the number of channels.",
Expand Down Expand Up @@ -813,6 +816,7 @@
"LabelReleaseDate": "Release date",
"LabelRemoteClientBitrateLimit": "Internet streaming bitrate limit (Mbps)",
"LabelRemoteClientBitrateLimitHelp": "An optional per-stream bitrate limit for all out of network devices. This is useful to prevent devices from requesting a higher bitrate than your internet connection can handle. This may result in increased CPU load on your server in order to transcode videos on the fly to a lower bitrate.",
"LabelRepository": "Repository",
"LabelRepositoryName": "Repository Name",
"LabelRepositoryNameHelp": "A custom name to distinguish this repository from any others added to your server.",
"LabelRepositoryUrl": "Repository URL",
Expand Down Expand Up @@ -1025,7 +1029,6 @@
"MenuOpen": "Open Menu",
"MenuClose": "Close Menu",
"MessageAddRepository": "If you wish to add a repository, click the button next to the header and fill out the requested information.",
"MessageAlreadyInstalled": "This version is already installed.",
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
"MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?",
"MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.",
Expand Down Expand Up @@ -1101,7 +1104,6 @@
"MessageUnableToConnectToServer": "We're unable to connect to the selected server right now. Please ensure it is running and try again.",
"MessageUnauthorizedUser": "You are not authorized to access the server at this time. Please contact your server administrator for more information.",
"MessageUnsetContentHelp": "Content will be displayed as plain folders. For best results use the metadata manager to set the content types of sub-folders.",
"MessageYouHaveVersionInstalled": "You currently have version {0} installed.",
"Metadata": "Metadata",
"MetadataManager": "Metadata Manager",
"MetadataSettingChangeHelp": "Changing metadata settings will affect new content added going forward. To refresh existing content, open the detail screen and click the 'Refresh' button, or do bulk refreshes using the 'Metadata Manager'.",
Expand Down Expand Up @@ -1282,11 +1284,15 @@
"PlayNext": "Play next",
"PlayNextEpisodeAutomatically": "Play next episode automatically",
"PleaseAddAtLeastOneFolder": "Please add at least one folder to this library by clicking the '+' button in 'Folders' section.",
"PleaseConfirmPluginInstallation": "Please click OK to confirm you've read the above and wish to proceed with the plugin installation.",
"PleaseConfirmRepositoryInstallation": "Please click OK to confirm you've read the above and wish to proceed with the plugin repository installation.",
"PleaseEnterNameOrId": "Please enter a name or an external ID.",
"PleaseRestartServerName": "Please restart Jellyfin on {0}.",
"PleaseSelectTwoItems": "Please select at least two items.",
"PluginDisableError": "An error occurred while disabling the plugin.",
"PluginEnableError": "An error occurred while enabling the plugin.",
"PluginLoadConfigError": "An error occurred while getting the plugin configuration pages.",
"PluginLoadRepoError": "An error occurred while getting the plugin details from the repository.",
"PluginUninstallError": "An error occurred while uninstalling the plugin.",
"Poster": "Poster",
"PosterCard": "Poster Card",
"PreferEmbeddedEpisodeInfosOverFileNames": "Prefer embedded episode information over filenames",
Expand Down Expand Up @@ -1314,7 +1320,6 @@
"ProductionLocations": "Production locations",
"Profile": "Profile",
"Programs": "Programs",
"PluginFromRepo": "{0} from repository {1}",
"Quality": "Quality",
"QuickConnect": "Quick Connect",
"QuickConnectActivationSuccessful": "Successfully activated",
Expand Down Expand Up @@ -1403,7 +1408,7 @@
"SeriesYearToPresent": "{0} - Present",
"ServerNameIsRestarting": "The server at {0} is restarting.",
"ServerNameIsShuttingDown": "The server at {0} is shutting down.",
"ServerRestartNeededAfterPluginInstall": "Jellyfin will need to be restarted after installing a plugin.",
"ServerRestartNeededAfterPluginInstall": "Jellyfin will need to be restarted after installing the plugin.",
"ServerUpdateNeeded": "This server needs to be updated. To download the latest version, please visit {0}",
"Settings": "Settings",
"SettingsSaved": "Settings saved.",
Expand Down
17 changes: 17 additions & 0 deletions src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Api } from '@jellyfin/sdk';

/**
* Gets a full URI for a relative URL to the Jellyfin server for a given SDK Api instance.
* TODO: Add to SDK
* @param api - The Jellyfin SDK Api instance.
* @param url - The relative URL.
* @returns The complete URI with protocol, host, and base URL (if any).
*/
export const getUri = (url: string, api?: Api) => {
if (!api) return;

return api.axiosInstance.getUri({
baseURL: api.basePath,
url
});
};