diff --git a/src/apps/dashboard/controllers/plugins/repositories/index.html b/src/apps/dashboard/controllers/plugins/repositories/index.html deleted file mode 100644 index a775da4b42..0000000000 --- a/src/apps/dashboard/controllers/plugins/repositories/index.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
-
-
-

${TabRepositories}

- -
- -
- -
-

${MessageNoRepositories}

-

${MessageAddRepository}

-
-
-
-
diff --git a/src/apps/dashboard/controllers/plugins/repositories/index.js b/src/apps/dashboard/controllers/plugins/repositories/index.js deleted file mode 100644 index 8eb0265f72..0000000000 --- a/src/apps/dashboard/controllers/plugins/repositories/index.js +++ /dev/null @@ -1,190 +0,0 @@ -import loading from 'components/loading/loading'; -import globalize from 'lib/globalize'; -import dialogHelper from 'components/dialogHelper/dialogHelper'; -import confirm from 'components/confirm/confirm'; - -import 'elements/emby-button/emby-button'; -import 'elements/emby-checkbox/emby-checkbox'; -import 'elements/emby-select/emby-select'; - -import 'components/formdialog.scss'; -import 'components/listview/listview.scss'; - -let repositories = []; - -function reloadList(page) { - loading.show(); - ApiClient.getJSON(ApiClient.getUrl('Repositories')).then(list => { - repositories = list; - populateList({ - listElement: page.querySelector('#repositories'), - noneElement: page.querySelector('#none'), - repositories: repositories - }); - }).catch(e => { - console.error('error loading repositories', e); - page.querySelector('#none').classList.remove('hide'); - loading.hide(); - }); -} - -function saveList(page) { - loading.show(); - ApiClient.ajax({ - type: 'POST', - url: ApiClient.getUrl('Repositories'), - data: JSON.stringify(repositories), - contentType: 'application/json' - }).then(() => { - reloadList(page); - }).catch(e => { - console.error('error saving repositories', e); - loading.hide(); - }); -} - -function populateList(options) { - const paperList = document.createElement('div'); - paperList.className = 'paperList'; - - options.repositories.forEach(repo => { - paperList.appendChild(getRepositoryElement(repo)); - }); - - if (!options.repositories.length) { - options.noneElement.classList.remove('hide'); - } else { - options.noneElement.classList.add('hide'); - } - - options.listElement.innerHTML = ''; - options.listElement.appendChild(paperList); - loading.hide(); -} - -function getRepositoryElement(repository) { - const listItem = document.createElement('div'); - listItem.className = 'listItem listItem-border'; - - const repoLink = document.createElement('a', 'emby-linkbutton'); - repoLink.classList.add('clearLink', 'listItemIconContainer'); - repoLink.style.margin = '0'; - repoLink.style.padding = '0'; - repoLink.rel = 'noopener noreferrer'; - repoLink.target = '_blank'; - repoLink.href = repository.Url; - repoLink.innerHTML = ''; - listItem.appendChild(repoLink); - - const body = document.createElement('div'); - body.className = 'listItemBody two-line'; - - const name = document.createElement('h3'); - name.className = 'listItemBodyText'; - name.innerText = repository.Name; - body.appendChild(name); - - const url = document.createElement('div'); - url.className = 'listItemBodyText secondary'; - url.innerText = repository.Url; - body.appendChild(url); - - listItem.appendChild(body); - - const button = document.createElement('button', 'paper-icon-button-light'); - button.type = 'button'; - button.classList.add('btnDelete'); - button.id = repository.Url; - button.title = globalize.translate('Delete'); - button.innerHTML = ''; - listItem.appendChild(button); - - return listItem; -} - -export default function(view) { - view.addEventListener('viewshow', function () { - reloadList(this); - - const save = this; - $('#repositories', view).on('click', '.btnDelete', function() { - const button = this; - repositories = repositories.filter(function (r) { - return r.Url !== button.id; - }); - - saveList(save); - }); - }); - - view.querySelector('.btnNewRepository').addEventListener('click', () => { - const dialog = dialogHelper.createDialog({ - scrollY: false, - size: 'large', - modal: false, - removeOnClose: true - }); - - let html = ''; - - html += '
'; - html += ``; - html += `

${globalize.translate('HeaderNewRepository')}

`; - html += '
'; - html += '
'; - html += '
'; - html += ``; - html += `
${globalize.translate('LabelRepositoryNameHelp')}
`; - html += '
'; - html += '
'; - html += ``; - html += `
${globalize.translate('LabelRepositoryUrlHelp')}
`; - html += '
'; - html += ``; - html += ''; - html += '
'; - - dialog.innerHTML = html; - dialog.querySelector('.btnCancel').addEventListener('click', () => { - dialogHelper.close(dialog); - }); - - dialog.querySelector('.newPluginForm').addEventListener('submit', e => { - e.preventDefault(); - - const repositoryUrl = dialog.querySelector('#txtRepositoryUrl').value.toLowerCase(); - - const alertCallback = function () { - repositories.push({ - Name: dialog.querySelector('#txtRepositoryName').value, - Url: dialog.querySelector('#txtRepositoryUrl').value, - Enabled: true - }); - saveList(view); - dialogHelper.close(dialog); - }; - - // Check the repository URL for the official Jellyfin repository domain, or - // present the warning for 3rd party plugins. - if (!repositoryUrl.startsWith('https://repo.jellyfin.org/')) { - let msg = globalize.translate('MessageRepositoryInstallDisclaimer'); - msg += '
'; - msg += '
'; - msg += globalize.translate('PleaseConfirmRepositoryInstallation'); - - confirm(msg, globalize.translate('HeaderConfirmRepositoryInstallation')).then(function () { - alertCallback(); - }).catch(() => { - console.debug('repository not installed'); - dialogHelper.close(dialog); - }); - } else { - alertCallback(); - } - - return false; - }); - - dialogHelper.open(dialog); - }); -} diff --git a/src/apps/dashboard/features/plugins/api/queryKey.ts b/src/apps/dashboard/features/plugins/api/queryKey.ts index 72b5d8fc3b..c999d3407c 100644 --- a/src/apps/dashboard/features/plugins/api/queryKey.ts +++ b/src/apps/dashboard/features/plugins/api/queryKey.ts @@ -1,5 +1,6 @@ export enum QueryKey { ConfigurationPages = 'ConfigurationPages', PackageInfo = 'PackageInfo', - Plugins = 'Plugins' + Plugins = 'Plugins', + Repositories = 'Repositories' } diff --git a/src/apps/dashboard/features/plugins/api/useRepositories.ts b/src/apps/dashboard/features/plugins/api/useRepositories.ts new file mode 100644 index 0000000000..27c5ef1fec --- /dev/null +++ b/src/apps/dashboard/features/plugins/api/useRepositories.ts @@ -0,0 +1,29 @@ +import type { Api } from '@jellyfin/sdk'; +import { queryOptions, useQuery } from '@tanstack/react-query'; +import type { AxiosRequestConfig } from 'axios'; +import { useApi } from 'hooks/useApi'; +import { QueryKey } from './queryKey'; +import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api'; + +const fetchRepositories = async ( + api: Api, + options?: AxiosRequestConfig +) => { + const response = await getPackageApi(api) + .getRepositories(options); + return response.data; +}; + +const getRepositoriesQuery = ( + api?: Api +) => queryOptions({ + queryKey: [ QueryKey.Repositories ], + queryFn: ({ signal }) => fetchRepositories(api!, { signal }), + enabled: !!api +}); + +export const useRepositories = () => { + const { api } = useApi(); + return useQuery(getRepositoriesQuery(api)); +}; + diff --git a/src/apps/dashboard/features/plugins/api/useSetRepositories.ts b/src/apps/dashboard/features/plugins/api/useSetRepositories.ts new file mode 100644 index 0000000000..62f9589755 --- /dev/null +++ b/src/apps/dashboard/features/plugins/api/useSetRepositories.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; +import { QueryKey } from './queryKey'; +import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api'; +import { PackageApiSetRepositoriesRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api'; + +export const useSetRepositories = () => { + const { api } = useApi(); + return useMutation({ + mutationFn: (params: PackageApiSetRepositoriesRequest) => ( + getPackageApi(api!) + .setRepositories(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ QueryKey.Repositories ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/plugins/components/NewRepositoryForm.tsx b/src/apps/dashboard/features/plugins/components/NewRepositoryForm.tsx new file mode 100644 index 0000000000..122d2c2313 --- /dev/null +++ b/src/apps/dashboard/features/plugins/components/NewRepositoryForm.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from 'react'; +import type { RepositoryInfo } from '@jellyfin/sdk/lib/generated-client/models/repository-info'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import globalize from 'lib/globalize'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; + +type IProps = { + open: boolean; + onClose: () => void; + onAdd: (repository: RepositoryInfo) => void; +}; + +const NewRepositoryForm = ({ open, onClose, onAdd }: IProps) => { + const onSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + const data = Object.fromEntries(formData.entries()); + + const repository: RepositoryInfo = { + Name: data.Name?.toString(), + Url: data.Url?.toString(), + Enabled: true + }; + + onAdd(repository); + }, [ onAdd ]); + + return ( + + {globalize.translate('HeaderNewRepository')} + + + + + + + + + + + + + + + ); +}; + +export default NewRepositoryForm; diff --git a/src/apps/dashboard/features/plugins/components/RepositoryListItem.tsx b/src/apps/dashboard/features/plugins/components/RepositoryListItem.tsx new file mode 100644 index 0000000000..c6d59f6ba6 --- /dev/null +++ b/src/apps/dashboard/features/plugins/components/RepositoryListItem.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useState } from 'react'; +import type { RepositoryInfo } from '@jellyfin/sdk/lib/generated-client/models/repository-info'; +import ListItem from '@mui/material/ListItem'; +import Tooltip from '@mui/material/Tooltip'; +import Delete from '@mui/icons-material/Delete'; +import globalize from 'lib/globalize'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import OpenInNew from '@mui/icons-material/OpenInNew'; +import Avatar from '@mui/material/Avatar'; +import ListItemButton from '@mui/material/ListItemButton'; +import Link from '@mui/material/Link'; +import ConfirmDialog from 'components/ConfirmDialog'; + +type IProps = { + repository: RepositoryInfo; + onDelete: (repository: RepositoryInfo) => void; +}; + +const RepositoryListItem = ({ repository, onDelete }: IProps) => { + const [ isConfirmDeleteOpen, setIsConfirmDeleteOpen ] = useState(false); + + const confirmDeletePrompt = useCallback(() => { + setIsConfirmDeleteOpen(true); + }, []); + + const onCancel = useCallback(() => { + setIsConfirmDeleteOpen(false); + }, []); + + const onConfirmDelete = useCallback(() => { + onDelete(repository); + setIsConfirmDeleteOpen(false); + }, [ onDelete, repository ]); + + return ( + <> + + + + + + + } + > + + + + + + + + + + + + + ); +}; + +export default RepositoryListItem; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 268e900801..4617c19822 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -19,6 +19,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/repositories', type: AppType.Dashboard }, { path: 'tasks', type: AppType.Dashboard }, { path: 'tasks/:id', page: 'tasks/task', type: AppType.Dashboard }, { path: 'users', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index 00ca6f057d..8d0a956792 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -30,13 +30,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'plugins/available/index', view: 'plugins/available/index.html' } - }, { - path: 'plugins/repositories', - pageProps: { - appType: AppType.Dashboard, - controller: 'plugins/repositories/index', - view: 'plugins/repositories/index.html' - } }, { path: 'livetv/guide', pageProps: { diff --git a/src/apps/dashboard/routes/plugins/repositories.tsx b/src/apps/dashboard/routes/plugins/repositories.tsx new file mode 100644 index 0000000000..3ce8579e27 --- /dev/null +++ b/src/apps/dashboard/routes/plugins/repositories.tsx @@ -0,0 +1,107 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import AddIcon from '@mui/icons-material/Add'; +import Page from 'components/Page'; +import globalize from 'lib/globalize'; +import React, { useCallback, useState } from 'react'; +import Stack from '@mui/material/Stack'; +import { useRepositories } from 'apps/dashboard/features/plugins/api/useRepositories'; +import Loading from 'components/loading/LoadingComponent'; +import Alert from '@mui/material/Alert'; +import List from '@mui/material/List'; +import RepositoryListItem from 'apps/dashboard/features/plugins/components/RepositoryListItem'; +import type { RepositoryInfo } from '@jellyfin/sdk/lib/generated-client/models/repository-info'; +import { useSetRepositories } from 'apps/dashboard/features/plugins/api/useSetRepositories'; +import NewRepositoryForm from 'apps/dashboard/features/plugins/components/NewRepositoryForm'; + +export const Component = () => { + const { data: repositories, isPending, isError } = useRepositories(); + const [ isRepositoryFormOpen, setIsRepositoryFormOpen ] = useState(false); + const setRepositories = useSetRepositories(); + + const onDelete = useCallback((repository: RepositoryInfo) => { + if (repositories) { + setRepositories.mutate({ + repositoryInfo: repositories.filter(currentRepo => currentRepo.Url !== repository.Url) + }); + } + }, [ repositories, setRepositories ]); + + const onRepositoryAdd = useCallback((repository: RepositoryInfo) => { + if (repositories) { + setRepositories.mutate({ + repositoryInfo: [ + ...repositories, + repository + ] + }, { + onSettled: () => { + setIsRepositoryFormOpen(false); + } + }); + } + }, [ repositories, setRepositories ]); + + const openRepositoryForm = useCallback(() => { + setIsRepositoryFormOpen(true); + }, []); + + const onRepositoryFormClose = useCallback(() => { + setIsRepositoryFormOpen(false); + }, []); + + if (isPending) { + return ; + } + + return ( + + + + {isError ? ( + {globalize.translate('RepositoriesPageLoadError')} + ) : ( + + {globalize.translate('TabRepositories')} + + + + {repositories.length > 0 ? ( + + {repositories.map(repository => { + return ; + })} + + ) : ( + + {globalize.translate('MessageNoRepositories')} + {globalize.translate('MessageAddRepository')} + + )} + + )} + + + ); +}; + +Component.displayName = 'PluginRepositoriesPage'; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 6909988d75..972b34f107 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -180,6 +180,7 @@ "ConfirmDeleteSeries": "Deleting this series will delete ALL {0} episodes from both the file system and your media library. Are you sure you wish to continue?", "ConfirmDeleteItems": "Deleting these items will delete them from both the file system and your media library. Are you sure you wish to continue?", "ConfirmDeleteLyrics": "Deleting these lyrics will delete them from both the file system and your media library. Are you sure you wish to continue?", + "ConfirmDeleteRepository": "Delete repository?", "ConfirmDeletion": "Confirm Deletion", "ConfirmEndPlayerSession": "Would you like to shutdown Jellyfin on {0}?", "Connect": "Connect", @@ -216,6 +217,7 @@ "DeleteCustomImage": "Delete Custom Image", "DeleteDeviceConfirmation": "Are you sure you wish to delete this device? It will reappear the next time a user signs in with it.", "DeleteDevicesConfirmation": "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.", + "DeleteRepositoryConfirmation": "Are you sure you want to delete this repository?", "DeleteImage": "Delete Image", "DeleteImageConfirmation": "Are you sure you wish to delete this image?", "DeleteLyrics": "Delete lyrics", @@ -1481,6 +1483,7 @@ "ReplaceAllMetadata": "Replace all metadata", "ReplaceExistingImages": "Replace existing images", "ReplaceTrickplayImages": "Replace existing trickplay images", + "RepositoriesPageLoadError": "Failed to load repositories", "Retry": "Retry", "RetryWithGlobalSearch": "Retry with a global search", "Reset": "Reset",