From 7281ce480d5faeaff4a563973ad8283e6af06f79 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Fri, 17 Oct 2025 23:52:29 +0300 Subject: [PATCH] Migrate libraries page to React (#7078) * Migrate libraries page to React * Fix aspect ratio for library card images * Fix loading * Use image url utility from TS SDK * Add width prop to BaseCard * Apply review feedback --- src/apps/dashboard/components/BaseCard.tsx | 62 ++- src/apps/dashboard/controllers/library.html | 14 - src/apps/dashboard/controllers/library.js | 383 ------------------ .../libraries/api/useRemoveVirtualFolder.ts | 21 + .../libraries/api/useRenameVirtualFolder.ts | 21 + .../libraries/api/useVirtualFolders.ts | 21 + .../libraries/components/LibraryCard.tsx | 237 +++++++++++ .../libraries/utils/collectionTypeOptions.ts | 31 ++ .../tasks/components/TaskProgress.tsx | 10 +- src/apps/dashboard/routes/_asyncRoutes.ts | 1 + src/apps/dashboard/routes/_legacyRoutes.ts | 7 - src/apps/dashboard/routes/libraries/index.tsx | 107 +++++ src/components/ConfirmDialog.tsx | 2 +- src/components/InputDialog.tsx | 16 +- 14 files changed, 504 insertions(+), 429 deletions(-) delete mode 100644 src/apps/dashboard/controllers/library.html delete mode 100644 src/apps/dashboard/controllers/library.js create mode 100644 src/apps/dashboard/features/libraries/api/useRemoveVirtualFolder.ts create mode 100644 src/apps/dashboard/features/libraries/api/useRenameVirtualFolder.ts create mode 100644 src/apps/dashboard/features/libraries/api/useVirtualFolders.ts create mode 100644 src/apps/dashboard/features/libraries/components/LibraryCard.tsx create mode 100644 src/apps/dashboard/features/libraries/utils/collectionTypeOptions.ts create mode 100644 src/apps/dashboard/routes/libraries/index.tsx diff --git a/src/apps/dashboard/components/BaseCard.tsx b/src/apps/dashboard/components/BaseCard.tsx index e97edbab5..954242d4f 100644 --- a/src/apps/dashboard/components/BaseCard.tsx +++ b/src/apps/dashboard/components/BaseCard.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; -import CardHeader from '@mui/material/CardHeader'; +import CardContent from '@mui/material/CardContent'; import CardMedia from '@mui/material/CardMedia'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; @@ -13,7 +13,6 @@ import { Link, To } from 'react-router-dom'; interface BaseCardProps { title?: string; - secondaryTitle?: string; text?: string; image?: string | null; icon?: React.ReactNode; @@ -22,15 +21,30 @@ interface BaseCardProps { action?: boolean; actionRef?: React.MutableRefObject; onActionClick?: () => void; + height?: number; + width?: number; }; -const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, action, actionRef, onActionClick }: BaseCardProps) => { +const BaseCard = ({ + title, + text, + image, + icon, + to, + onClick, + action, + actionRef, + onActionClick, + height, + width +}: BaseCardProps) => { return ( )} - - + + + {title} - {secondaryTitle && ( - {secondaryTitle} + {text && ( + + {text} + )} - } - subheader={text} - action={ - action ? ( - - - - ) : null - } - /> + + {action ? ( + + + + ) : null} + + + ); }; diff --git a/src/apps/dashboard/controllers/library.html b/src/apps/dashboard/controllers/library.html deleted file mode 100644 index 77da0039b..000000000 --- a/src/apps/dashboard/controllers/library.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
-
-
- - -
- -
-
-
-
diff --git a/src/apps/dashboard/controllers/library.js b/src/apps/dashboard/controllers/library.js deleted file mode 100644 index 5f7ca8b91..000000000 --- a/src/apps/dashboard/controllers/library.js +++ /dev/null @@ -1,383 +0,0 @@ -import escapeHtml from 'escape-html'; - -import taskButton from 'scripts/taskbutton'; -import loading from 'components/loading/loading'; -import globalize from 'lib/globalize'; -import dom from 'utils/dom'; -import imageHelper from 'utils/image'; -import 'components/cardbuilder/card.scss'; -import 'elements/emby-itemrefreshindicator/emby-itemrefreshindicator'; -import { pageClassOn, pageIdOn } from 'utils/dashboard'; -import confirm from 'components/confirm/confirm'; -import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils'; - -function addVirtualFolder(page) { - import('components/mediaLibraryCreator/mediaLibraryCreator').then(({ default: MediaLibraryCreator }) => { - new MediaLibraryCreator({ - collectionTypeOptions: getCollectionTypeOptions().filter(function (f) { - return !f.hidden; - }), - refresh: shouldRefreshLibraryAfterChanges(page) - }).then(function (hasChanges) { - if (hasChanges) { - reloadLibrary(page); - } - }); - }); -} - -function editVirtualFolder(page, virtualFolder) { - import('components/mediaLibraryEditor/mediaLibraryEditor').then(({ default: MediaLibraryEditor }) => { - new MediaLibraryEditor({ - refresh: shouldRefreshLibraryAfterChanges(page), - library: virtualFolder - }).then(function (hasChanges) { - if (hasChanges) { - reloadLibrary(page); - } - }); - }); -} - -function deleteVirtualFolder(page, virtualFolder) { - let msg = globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder'); - - if (virtualFolder.Locations.length) { - msg += '

' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '

'; - msg += virtualFolder.Locations.join('
'); - } - - confirm({ - text: msg, - title: globalize.translate('HeaderRemoveMediaFolder'), - confirmText: globalize.translate('Delete'), - primary: 'delete' - }).then(function () { - const refreshAfterChange = shouldRefreshLibraryAfterChanges(page); - ApiClient.removeVirtualFolder(virtualFolder.Name, refreshAfterChange).then(function () { - reloadLibrary(page); - }); - }); -} - -function refreshVirtualFolder(page, virtualFolder) { - import('components/refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => { - new RefreshDialog({ - itemIds: [virtualFolder.ItemId], - serverId: ApiClient.serverId(), - mode: 'scan' - }).show(); - }); -} - -function renameVirtualFolder(page, virtualFolder) { - import('components/prompt/prompt').then(({ default: prompt }) => { - prompt({ - label: globalize.translate('LabelNewName'), - description: globalize.translate('MessageRenameMediaFolder'), - confirmText: globalize.translate('ButtonRename') - }).then(function (newName) { - if (newName && newName != virtualFolder.Name) { - const refreshAfterChange = shouldRefreshLibraryAfterChanges(page); - ApiClient.renameVirtualFolder(virtualFolder.Name, newName, refreshAfterChange).then(function () { - reloadLibrary(page); - }); - } - }); - }); -} - -function showCardMenu(page, elem, virtualFolders) { - const card = dom.parentWithClass(elem, 'card'); - const index = parseInt(card.getAttribute('data-index'), 10); - const virtualFolder = virtualFolders[index]; - const menuItems = []; - menuItems.push({ - name: globalize.translate('EditImages'), - id: 'editimages', - icon: 'photo' - }); - menuItems.push({ - name: globalize.translate('ManageLibrary'), - id: 'edit', - icon: 'folder' - }); - menuItems.push({ - name: globalize.translate('ButtonRename'), - id: 'rename', - icon: 'mode_edit' - }); - menuItems.push({ - name: globalize.translate('ScanLibrary'), - id: 'refresh', - icon: 'refresh' - }); - menuItems.push({ - name: globalize.translate('ButtonRemove'), - id: 'delete', - icon: 'delete' - }); - - import('components/actionSheet/actionSheet').then((actionsheet) => { - actionsheet.show({ - items: menuItems, - positionTo: elem, - callback: function (resultId) { - switch (resultId) { - case 'edit': - editVirtualFolder(page, virtualFolder); - break; - - case 'editimages': - editImages(page, virtualFolder); - break; - - case 'rename': - renameVirtualFolder(page, virtualFolder); - break; - - case 'delete': - deleteVirtualFolder(page, virtualFolder); - break; - - case 'refresh': - refreshVirtualFolder(page, virtualFolder); - } - } - }); - }); -} - -function reloadLibrary(page) { - loading.show(); - ApiClient.getVirtualFolders().then(function (result) { - reloadVirtualFolders(page, result); - }); -} - -function shouldRefreshLibraryAfterChanges(page) { - return page.id === 'mediaLibraryPage'; -} - -function reloadVirtualFolders(page, virtualFolders) { - let html = ''; - virtualFolders.push({ - Name: globalize.translate('ButtonAddMediaLibrary'), - icon: 'add_circle', - Locations: [], - showType: false, - showLocations: false, - showMenu: false, - showNameWithIcon: false, - elementId: 'addLibrary' - }); - - for (let i = 0; i < virtualFolders.length; i++) { - const virtualFolder = virtualFolders[i]; - html += getVirtualFolderHtml(page, virtualFolder, i); - } - - const divVirtualFolders = page.querySelector('#divVirtualFolders'); - divVirtualFolders.innerHTML = html; - divVirtualFolders.classList.add('itemsContainer'); - divVirtualFolders.classList.add('vertical-wrap'); - const btnCardMenuElements = divVirtualFolders.querySelectorAll('.btnCardMenu'); - btnCardMenuElements.forEach(function (btn) { - btn.addEventListener('click', function () { - showCardMenu(page, btn, virtualFolders); - }); - }); - divVirtualFolders.querySelector('#addLibrary').addEventListener('click', function () { - addVirtualFolder(page); - }); - - const libraryEditElements = divVirtualFolders.querySelectorAll('.editLibrary'); - libraryEditElements.forEach(function (btn) { - btn.addEventListener('click', function () { - const card = dom.parentWithClass(btn, 'card'); - const index = parseInt(card.getAttribute('data-index'), 10); - const virtualFolder = virtualFolders[index]; - - if (virtualFolder.ItemId) { - editVirtualFolder(page, virtualFolder); - } - }); - }); - loading.hide(); -} - -function editImages(page, virtualFolder) { - import('components/imageeditor/imageeditor').then((imageEditor) => { - imageEditor.show({ - itemId: virtualFolder.ItemId, - serverId: ApiClient.serverId() - }).then(function () { - reloadLibrary(page); - }); - }); -} - -function getLink(text, url) { - return globalize.translate(text, '', ''); -} - -function getCollectionTypeOptions() { - return [{ - name: '', - value: '' - }, { - name: globalize.translate('Movies'), - value: 'movies', - message: getLink('MovieLibraryHelp', 'https://jellyfin.org/docs/general/server/media/movies') - }, { - name: globalize.translate('TabMusic'), - value: 'music', - message: getLink('MusicLibraryHelp', 'https://jellyfin.org/docs/general/server/media/music') - }, { - name: globalize.translate('Shows'), - value: 'tvshows', - message: getLink('TvLibraryHelp', 'https://jellyfin.org/docs/general/server/media/shows') - }, { - name: globalize.translate('Books'), - value: 'books', - message: getLink('BookLibraryHelp', 'https://jellyfin.org/docs/general/server/media/books') - }, { - name: globalize.translate('HomeVideosPhotos'), - value: 'homevideos' - }, { - name: globalize.translate('MusicVideos'), - value: 'musicvideos' - }, { - name: globalize.translate('MixedMoviesShows'), - value: 'mixed', - message: globalize.translate('MessageUnsetContentHelp') - }]; -} - -function getVirtualFolderHtml(page, virtualFolder, index) { - let html = ''; - - const elementId = virtualFolder.elementId ? `id="${virtualFolder.elementId}" ` : ''; - html += '
'; - - html += '
'; - html += '
'; - html += '
'; - html += '
'; - let imgUrl = ''; - - if (virtualFolder.PrimaryImageItemId) { - imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, { - maxWidth: Math.round(dom.getScreenWidth() * 0.40), - type: 'Primary' - }); - } - - let hasCardImageContainer; - - if (imgUrl) { - html += `
`; - html += ``; - hasCardImageContainer = true; - } else if (!virtualFolder.showNameWithIcon) { - html += `
`; - html += ''; - hasCardImageContainer = true; - } - - if (hasCardImageContainer) { - html += '
'; - html += '
'; - html += '
'; - html += '
'; - } - - if (!imgUrl && virtualFolder.showNameWithIcon) { - html += '

'; - html += ''; - - if (virtualFolder.showNameWithIcon) { - html += '
'; - html += escapeHtml(virtualFolder.Name); - html += '
'; - } - - html += '

'; - } - - html += '
'; - html += '
'; - html += '
'; // always show menu unless explicitly hidden - - if (virtualFolder.showMenu !== false) { - const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right'; - html += '
'; - html += ''; - html += '
'; - } - - html += "
"; - - if (virtualFolder.showNameWithIcon) { - html += ' '; - } else { - html += escapeHtml(virtualFolder.Name); - } - - html += '
'; - let typeName = getCollectionTypeOptions().filter(function (t) { - return t.value == virtualFolder.CollectionType; - })[0]; - typeName = typeName ? typeName.name : globalize.translate('Other'); - html += "
"; - - if (virtualFolder.showType === false) { - html += ' '; - } else { - html += typeName; - } - - html += '
'; - - if (virtualFolder.showLocations === false) { - html += "
"; - html += ' '; - html += '
'; - } else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) { - html += "
"; - html += escapeHtml(virtualFolder.Locations[0]); - html += '
'; - } else { - html += "
"; - html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length); - html += '
'; - } - - html += '
'; - html += '
'; - html += '
'; - return html; -} - -pageClassOn('pageshow', 'mediaLibraryPage', function () { - reloadLibrary(this); -}); -pageIdOn('pageshow', 'mediaLibraryPage', function () { - const page = this; - taskButton({ - mode: 'on', - progressElem: page.querySelector('.refreshProgress'), - taskKey: 'RefreshLibrary', - button: page.querySelector('.btnRefresh') - }); -}); -pageIdOn('pagebeforehide', 'mediaLibraryPage', function () { - const page = this; - taskButton({ - mode: 'off', - progressElem: page.querySelector('.refreshProgress'), - taskKey: 'RefreshLibrary', - button: page.querySelector('.btnRefresh') - }); -}); - diff --git a/src/apps/dashboard/features/libraries/api/useRemoveVirtualFolder.ts b/src/apps/dashboard/features/libraries/api/useRemoveVirtualFolder.ts new file mode 100644 index 000000000..be3579b4c --- /dev/null +++ b/src/apps/dashboard/features/libraries/api/useRemoveVirtualFolder.ts @@ -0,0 +1,21 @@ +import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api'; +import { LibraryStructureApiRemoveVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api'; +import { useMutation } from '@tanstack/react-query'; + +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; + +export const useRemoveVirtualFolder = () => { + const { api } = useApi(); + return useMutation({ + mutationFn: (params: LibraryStructureApiRemoveVirtualFolderRequest) => ( + getLibraryStructureApi(api!) + .removeVirtualFolder(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ 'VirtualFolders' ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/libraries/api/useRenameVirtualFolder.ts b/src/apps/dashboard/features/libraries/api/useRenameVirtualFolder.ts new file mode 100644 index 000000000..ea02f1b75 --- /dev/null +++ b/src/apps/dashboard/features/libraries/api/useRenameVirtualFolder.ts @@ -0,0 +1,21 @@ +import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api'; +import { LibraryStructureApiRenameVirtualFolderRequest } from '@jellyfin/sdk/lib/generated-client/api/library-structure-api'; +import { useMutation } from '@tanstack/react-query'; + +import { useApi } from 'hooks/useApi'; +import { queryClient } from 'utils/query/queryClient'; + +export const useRenameVirtualFolder = () => { + const { api } = useApi(); + return useMutation({ + mutationFn: (params: LibraryStructureApiRenameVirtualFolderRequest) => ( + getLibraryStructureApi(api!) + .renameVirtualFolder(params) + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [ 'VirtualFolders' ] + }); + } + }); +}; diff --git a/src/apps/dashboard/features/libraries/api/useVirtualFolders.ts b/src/apps/dashboard/features/libraries/api/useVirtualFolders.ts new file mode 100644 index 000000000..4d46cc955 --- /dev/null +++ b/src/apps/dashboard/features/libraries/api/useVirtualFolders.ts @@ -0,0 +1,21 @@ +import { Api } from '@jellyfin/sdk'; +import { useQuery } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import type { AxiosRequestConfig } from 'axios'; +import { getLibraryStructureApi } from '@jellyfin/sdk/lib/utils/api/library-structure-api'; + +const fetchVirtualFolders = async (api: Api, options?: AxiosRequestConfig) => { + const response = await getLibraryStructureApi(api).getVirtualFolders(options); + + return response.data; +}; + +export const useVirtualFolders = () => { + const { api } = useApi(); + + return useQuery({ + queryKey: [ 'VirtualFolders' ], + queryFn: ({ signal }) => fetchVirtualFolders(api!, { signal }), + enabled: !!api + }); +}; diff --git a/src/apps/dashboard/features/libraries/components/LibraryCard.tsx b/src/apps/dashboard/features/libraries/components/LibraryCard.tsx new file mode 100644 index 000000000..a7e898560 --- /dev/null +++ b/src/apps/dashboard/features/libraries/components/LibraryCard.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import type { VirtualFolderInfo } from '@jellyfin/sdk/lib/generated-client/models/virtual-folder-info'; +import BaseCard from 'apps/dashboard/components/BaseCard'; +import getCollectionTypeOptions from '../utils/collectionTypeOptions'; +import globalize from 'lib/globalize'; +import Icon from '@mui/material/Icon'; +import { getLibraryIcon } from 'utils/image'; +import MediaLibraryEditor from 'components/mediaLibraryEditor/mediaLibraryEditor'; +import { queryClient } from 'utils/query/queryClient'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Folder from '@mui/icons-material/Folder'; +import ImageIcon from '@mui/icons-material/Image'; +import EditIcon from '@mui/icons-material/Edit'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ListItemText from '@mui/material/ListItemText'; +import imageeditor from 'components/imageeditor/imageeditor'; +import { ServerConnections } from 'lib/jellyfin-apiclient'; +import InputDialog from 'components/InputDialog'; +import { useRenameVirtualFolder } from '../api/useRenameVirtualFolder'; +import RefreshDialog from 'components/refreshdialog/refreshdialog'; +import ConfirmDialog from 'components/ConfirmDialog'; +import { useRemoveVirtualFolder } from '../api/useRemoveVirtualFolder'; +import { getImageApi } from '@jellyfin/sdk/lib/utils/api/image-api'; +import { useApi } from 'hooks/useApi'; +import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; +import dom from 'utils/dom'; + +type LibraryCardProps = { + virtualFolder: VirtualFolderInfo; +}; + +const LibraryCard = ({ virtualFolder }: LibraryCardProps) => { + const { api } = useApi(); + const actionRef = useRef(null); + const [ anchorEl, setAnchorEl ] = useState(null); + const [ isMenuOpen, setIsMenuOpen ] = useState(false); + const [ isRenameLibraryDialogOpen, setIsRenameLibraryDialogOpen ] = useState(false); + const [ isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen ] = useState(false); + const renameVirtualFolder = useRenameVirtualFolder(); + const removeVirtualFolder = useRemoveVirtualFolder(); + + const imageUrl = useMemo(() => { + if (virtualFolder.PrimaryImageItemId && virtualFolder.ItemId && api) { + return getImageApi(api) + .getItemImageUrlById(virtualFolder.ItemId, ImageType.Primary, { + maxWidth: Math.round(dom.getScreenWidth() * 0.40) + }); + } + }, [ api, virtualFolder ]); + + const typeName = getCollectionTypeOptions().filter(function (t) { + return t.value == virtualFolder.CollectionType; + })[0]?.name || globalize.translate('Other'); + + const openRenameDialog = useCallback(() => { + setAnchorEl(null); + setIsMenuOpen(false); + setIsRenameLibraryDialogOpen(true); + }, []); + + const hideRenameLibraryDialog = useCallback(() => { + setIsRenameLibraryDialogOpen(false); + }, []); + + const onMenuClose = useCallback(() => { + setAnchorEl(null); + setIsMenuOpen(false); + }, []); + + const onActionClick = useCallback(() => { + setAnchorEl(actionRef.current); + setIsMenuOpen(true); + }, []); + + const renameLibrary = useCallback((newName: string) => { + if (virtualFolder.Name) { + renameVirtualFolder.mutate({ + refreshLibrary: true, + newName: newName, + name: virtualFolder.Name + }, { + onSettled: () => { + hideRenameLibraryDialog(); + } + }); + } + }, [ renameVirtualFolder, virtualFolder, hideRenameLibraryDialog ]); + + const showRefreshDialog = useCallback(() => { + setAnchorEl(null); + setIsMenuOpen(false); + + void new RefreshDialog({ + itemIds: [ virtualFolder.ItemId ], + serverId: ServerConnections.currentApiClient()?.serverId(), + mode: 'scan' + }).show(); + }, [ virtualFolder ]); + + const showMediaLibraryEditor = useCallback(() => { + setAnchorEl(null); + setIsMenuOpen(false); + + const mediaLibraryEditor = new MediaLibraryEditor({ + library: virtualFolder + }) as Promise; + + void mediaLibraryEditor.then((hasChanges: boolean) => { + if (hasChanges) { + void queryClient.invalidateQueries({ + queryKey: ['VirtualFolders'] + }); + } + }); + }, [ virtualFolder ]); + + const showImageEditor = useCallback(() => { + setAnchorEl(null); + setIsMenuOpen(false); + + void imageeditor.show({ + itemId: virtualFolder.ItemId, + serverId: ServerConnections.currentApiClient()?.serverId() + }).then(() => { + void queryClient.invalidateQueries({ + queryKey: ['VirtualFolders'] + }); + }).catch(() => { + /* pop up closed */ + }); + }, [ virtualFolder ]); + + const showDeleteLibraryDialog = useCallback(() => { + setAnchorEl(null); + setIsMenuOpen(false); + setIsConfirmDeleteDialogOpen(true); + }, []); + + const onCancelDeleteLibrary = useCallback(() => { + setIsConfirmDeleteDialogOpen(false); + }, []); + + const onConfirmDeleteLibrary = useCallback(() => { + if (virtualFolder.Name) { + removeVirtualFolder.mutate({ + name: virtualFolder.Name, + refreshLibrary: true + }, { + onSettled: () => { + setIsConfirmDeleteDialogOpen(false); + } + }); + } + }, [ virtualFolder, removeVirtualFolder ]); + + return ( + <> + + + + + {getLibraryIcon(virtualFolder.CollectionType)}} + action={true} + actionRef={actionRef} + onActionClick={onActionClick} + onClick={showMediaLibraryEditor} + height={260} + /> + + + + + + {globalize.translate('EditImages')} + + + + + + {globalize.translate('ManageLibrary')} + + + + + + {globalize.translate('ButtonRename')} + + + + + + {globalize.translate('ScanLibrary')} + + + + + + {globalize.translate('ButtonRemove')} + + + + ); +}; + +export default LibraryCard; diff --git a/src/apps/dashboard/features/libraries/utils/collectionTypeOptions.ts b/src/apps/dashboard/features/libraries/utils/collectionTypeOptions.ts new file mode 100644 index 000000000..22b9ac7b9 --- /dev/null +++ b/src/apps/dashboard/features/libraries/utils/collectionTypeOptions.ts @@ -0,0 +1,31 @@ +import globalize from 'lib/globalize'; + +const getCollectionTypeOptions = () => { + return [{ + name: '', + value: '' + }, { + name: globalize.translate('Movies'), + value: 'movies' + }, { + name: globalize.translate('TabMusic'), + value: 'music' + }, { + name: globalize.translate('Shows'), + value: 'tvshows' + }, { + name: globalize.translate('Books'), + value: 'books' + }, { + name: globalize.translate('HomeVideosPhotos'), + value: 'homevideos' + }, { + name: globalize.translate('MusicVideos'), + value: 'musicvideos' + }, { + name: globalize.translate('MixedMoviesShows'), + value: 'mixed' + }]; +}; + +export default getCollectionTypeOptions; diff --git a/src/apps/dashboard/features/tasks/components/TaskProgress.tsx b/src/apps/dashboard/features/tasks/components/TaskProgress.tsx index 6482dd57f..695af8423 100644 --- a/src/apps/dashboard/features/tasks/components/TaskProgress.tsx +++ b/src/apps/dashboard/features/tasks/components/TaskProgress.tsx @@ -8,7 +8,15 @@ const TaskProgress: FunctionComponent = ({ task }: TaskProps) => { const progress = task.CurrentProgressPercentage; return ( - + {progress != null ? ( <> diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 61f61f999..ded4d2b53 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -9,6 +9,7 @@ export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ { path: 'devices', type: AppType.Dashboard }, { path: 'settings', type: AppType.Dashboard }, { path: 'keys', type: AppType.Dashboard }, + { path: 'libraries', type: AppType.Dashboard }, { path: 'libraries/display', type: AppType.Dashboard }, { path: 'libraries/metadata', type: AppType.Dashboard }, { path: 'libraries/nfo', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index e3911d3b4..31b96d934 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -9,13 +9,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ controller: 'networking', view: 'networking.html' } - }, { - path: 'libraries', - pageProps: { - appType: AppType.Dashboard, - controller: 'library', - view: 'library.html' - } }, { path: 'livetv/guide', pageProps: { diff --git a/src/apps/dashboard/routes/libraries/index.tsx b/src/apps/dashboard/routes/libraries/index.tsx new file mode 100644 index 000000000..a5b8d656f --- /dev/null +++ b/src/apps/dashboard/routes/libraries/index.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useMemo } from 'react'; +import Page from 'components/Page'; +import globalize from 'lib/globalize'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import { useVirtualFolders } from 'apps/dashboard/features/libraries/api/useVirtualFolders'; +import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks'; +import { useStartTask } from 'apps/dashboard/features/tasks/api/useStartTask'; +import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress'; +import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state'; +import Grid from '@mui/material/Grid'; +import LibraryCard from 'apps/dashboard/features/libraries/components/LibraryCard'; +import Loading from 'components/loading/LoadingComponent'; +import MediaLibraryCreator from 'components/mediaLibraryCreator/mediaLibraryCreator'; +import getCollectionTypeOptions from 'apps/dashboard/features/libraries/utils/collectionTypeOptions'; +import { queryClient } from 'utils/query/queryClient'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import Add from '@mui/icons-material/Add'; + +export const Component = () => { + const { data: virtualFolders, isPending: isVirtualFoldersPending } = useVirtualFolders(); + const startTask = useStartTask(); + const { data: tasks, isPending: isLiveTasksPending } = useLiveTasks({ isHidden: false }); + + const librariesTask = useMemo(() => ( + tasks?.find((value) => value.Key === 'RefreshLibrary') + ), [ tasks ]); + + const showMediaLibraryCreator = useCallback(() => { + const mediaLibraryCreator = new MediaLibraryCreator({ + collectionTypeOptions: getCollectionTypeOptions() + }) as Promise; + + void mediaLibraryCreator.then((hasChanges: boolean) => { + if (hasChanges) { + void queryClient.invalidateQueries({ + queryKey: ['VirtualFolders'] + }); + } + }); + }, []); + + const onScanLibraries = useCallback(() => { + if (librariesTask?.Id) { + startTask.mutate({ + taskId: librariesTask.Id + }); + } + }, [ startTask, librariesTask ]); + + if (isVirtualFoldersPending || isLiveTasksPending) return ; + + return ( + + + + + + + {(librariesTask && librariesTask.State == TaskState.Running) && ( + + )} + + + + + {virtualFolders?.map(virtualFolder => ( + + + + ))} + + + + + + ); +}; + +Component.displayName = 'LibrariesPage'; diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index bc3c31ced..53d8ba46a 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -32,7 +32,7 @@ const ConfirmDialog: FC = ({ {title} - + {text} diff --git a/src/components/InputDialog.tsx b/src/components/InputDialog.tsx index 727e26319..dffbda185 100644 --- a/src/components/InputDialog.tsx +++ b/src/components/InputDialog.tsx @@ -12,13 +12,23 @@ interface InputDialogProps extends DialogProps { title: string; label: string; helperText?: string; + initialText?: string; confirmButtonText?: string; onClose: () => void; onConfirm: (text: string) => void; }; -const InputDialog = ({ open, title, label, helperText, onClose, confirmButtonText, onConfirm }: InputDialogProps) => { - const [ text, setText ] = useState(''); +const InputDialog = ({ + open, + title, + label, + helperText, + initialText, + onClose, + confirmButtonText, + onConfirm +}: InputDialogProps) => { + const [ text, setText ] = useState(initialText || ''); const onTextChange = useCallback((e: React.ChangeEvent) => { setText(e.target.value); @@ -38,7 +48,7 @@ const InputDialog = ({ open, title, label, helperText, onClose, confirmButtonTex > {title && ( - {title} + {title || ''} )}