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
This commit is contained in:
@@ -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<HTMLButtonElement | null>;
|
||||
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 (
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 240
|
||||
height: height || 240,
|
||||
width: width
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
@@ -62,30 +76,38 @@ const BaseCard = ({ title, secondaryTitle, text, image, icon, to, onClick, actio
|
||||
</Box>
|
||||
)}
|
||||
</CardActionArea>
|
||||
<CardHeader
|
||||
title={
|
||||
<Stack direction='row' spacing={1} alignItems='center'>
|
||||
<Typography sx={{
|
||||
<CardContent
|
||||
sx={{
|
||||
minHeight: 50,
|
||||
'&:last-child': {
|
||||
paddingBottom: 2,
|
||||
paddingRight: 1
|
||||
}
|
||||
}}>
|
||||
<Stack flexGrow={1} direction='row'>
|
||||
<Stack flexGrow={1}>
|
||||
<Typography gutterBottom sx={{
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{title}
|
||||
</Typography>
|
||||
{secondaryTitle && (
|
||||
<Typography variant='body2' color='text.secondary'>{secondaryTitle}</Typography>
|
||||
{text && (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
subheader={text}
|
||||
action={
|
||||
action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
{action ? (
|
||||
<IconButton ref={actionRef} onClick={onActionClick}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<div id="mediaLibraryPage" data-role="page" class="page type-interior mediaLibraryPage librarySectionPage fullWidthContent" data-title="${HeaderLibraries}">
|
||||
<div>
|
||||
<div class="content-primary">
|
||||
<div class="padded-top padded-bottom">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<progress max="100" min="0" style="display: inline-block; vertical-align: middle;" class="refreshProgress"></progress>
|
||||
</div>
|
||||
|
||||
<div id="divVirtualFolders"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 += '<br/><br/>' + globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '<br/><br/>';
|
||||
msg += virtualFolder.Locations.join('<br/>');
|
||||
}
|
||||
|
||||
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, '<a is="emby-linkbutton" class="button-link" href="' + url + '" target="_blank" data-autohide="true">', '</a>');
|
||||
}
|
||||
|
||||
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 += '<div ' + elementId + 'class="card backdropCard scalableCard backdropCard-scalable" data-index="' + index + '" data-id="' + virtualFolder.ItemId + '">';
|
||||
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += '<div class="cardContent">';
|
||||
let imgUrl = '';
|
||||
|
||||
if (virtualFolder.PrimaryImageItemId) {
|
||||
imgUrl = ApiClient.getScaledImageUrl(virtualFolder.PrimaryImageItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.40),
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
|
||||
let hasCardImageContainer;
|
||||
|
||||
if (imgUrl) {
|
||||
html += `<div class="cardImageContainer editLibrary ${imgUrl ? '' : getDefaultBackgroundClass()}" style="cursor:pointer">`;
|
||||
html += `<img src="${imgUrl}" style="width:100%" />`;
|
||||
hasCardImageContainer = true;
|
||||
} else if (!virtualFolder.showNameWithIcon) {
|
||||
html += `<div class="cardImageContainer editLibrary ${getDefaultBackgroundClass()}" style="cursor:pointer;">`;
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
hasCardImageContainer = true;
|
||||
}
|
||||
|
||||
if (hasCardImageContainer) {
|
||||
html += '<div class="cardIndicators backdropCardIndicators">';
|
||||
html += '<div is="emby-itemrefreshindicator"' + (virtualFolder.RefreshProgress || virtualFolder.RefreshStatus && virtualFolder.RefreshStatus !== 'Idle' ? '' : ' class="hide"') + ' data-progress="' + (virtualFolder.RefreshProgress || 0) + '" data-status="' + virtualFolder.RefreshStatus + '"></div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (!imgUrl && virtualFolder.showNameWithIcon) {
|
||||
html += '<h3 class="cardImageContainer addLibrary" style="position:absolute;top:0;left:0;right:0;bottom:0;cursor:pointer;flex-direction:column;">';
|
||||
html += '<span class="cardImageIcon material-icons ' + (virtualFolder.icon || imageHelper.getLibraryIcon(virtualFolder.CollectionType)) + '" aria-hidden="true"></span>';
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += '<div style="margin:1em 0;position:width:100%;">';
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</h3>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="cardFooter visualCardBox-cardFooter">'; // always show menu unless explicitly hidden
|
||||
|
||||
if (virtualFolder.showMenu !== false) {
|
||||
const dirTextAlign = globalize.getIsRTL() ? 'left' : 'right';
|
||||
html += '<div style="text-align:' + dirTextAlign + '; float:' + dirTextAlign + ';padding-top:5px;">';
|
||||
html += '<button type="button" is="paper-icon-button-light" class="btnCardMenu autoSize"><span class="material-icons more_vert" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += "<div class='cardText'>";
|
||||
|
||||
if (virtualFolder.showNameWithIcon) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += escapeHtml(virtualFolder.Name);
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
let typeName = getCollectionTypeOptions().filter(function (t) {
|
||||
return t.value == virtualFolder.CollectionType;
|
||||
})[0];
|
||||
typeName = typeName ? typeName.name : globalize.translate('Other');
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
|
||||
if (virtualFolder.showType === false) {
|
||||
html += ' ';
|
||||
} else {
|
||||
html += typeName;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (virtualFolder.showLocations === false) {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += ' ';
|
||||
html += '</div>';
|
||||
} else if (virtualFolder.Locations.length && virtualFolder.Locations.length === 1) {
|
||||
html += "<div class='cardText cardText-secondary' dir='ltr' style='text-align:left;'>";
|
||||
html += escapeHtml(virtualFolder.Locations[0]);
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += "<div class='cardText cardText-secondary'>";
|
||||
html += globalize.translate('NumLocationsValue', virtualFolder.Locations.length);
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
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')
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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' ]
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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<HTMLButtonElement | null>(null);
|
||||
const [ anchorEl, setAnchorEl ] = useState<HTMLElement | null>(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<boolean>;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<InputDialog
|
||||
title={globalize.translate('ButtonRename')}
|
||||
open={isRenameLibraryDialogOpen}
|
||||
onClose={hideRenameLibraryDialog}
|
||||
label={globalize.translate('LabelNewName')}
|
||||
helperText={globalize.translate('MessageRenameMediaFolder')}
|
||||
initialText={virtualFolder.Name || ''}
|
||||
confirmButtonText={globalize.translate('ButtonRename')}
|
||||
onConfirm={renameLibrary}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isConfirmDeleteDialogOpen}
|
||||
title={globalize.translate('HeaderRemoveMediaFolder')}
|
||||
text={
|
||||
globalize.translate('MessageAreYouSureYouWishToRemoveMediaFolder') + '\n\n'
|
||||
+ globalize.translate('MessageTheFollowingLocationWillBeRemovedFromLibrary') + '\n\n'
|
||||
+ virtualFolder.Locations?.join('\n')
|
||||
}
|
||||
confirmButtonText={globalize.translate('Delete')}
|
||||
confirmButtonColor='error'
|
||||
onConfirm={onConfirmDeleteLibrary}
|
||||
onCancel={onCancelDeleteLibrary}
|
||||
/>
|
||||
|
||||
<BaseCard
|
||||
title={virtualFolder.Name || ''}
|
||||
text={typeName}
|
||||
image={imageUrl}
|
||||
icon={<Icon sx={{ fontSize: 70 }}>{getLibraryIcon(virtualFolder.CollectionType)}</Icon>}
|
||||
action={true}
|
||||
actionRef={actionRef}
|
||||
onActionClick={onActionClick}
|
||||
onClick={showMediaLibraryEditor}
|
||||
height={260}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={isMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
>
|
||||
<MenuItem onClick={showImageEditor}>
|
||||
<ListItemIcon>
|
||||
<ImageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('EditImages')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showMediaLibraryEditor}>
|
||||
<ListItemIcon>
|
||||
<Folder />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ManageLibrary')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openRenameDialog}>
|
||||
<ListItemIcon>
|
||||
<EditIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonRename')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showRefreshDialog}>
|
||||
<ListItemIcon>
|
||||
<RefreshIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ScanLibrary')}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={showDeleteLibraryDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{globalize.translate('ButtonRemove')}</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryCard;
|
||||
@@ -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;
|
||||
@@ -8,7 +8,15 @@ const TaskProgress: FunctionComponent<TaskProps> = ({ task }: TaskProps) => {
|
||||
const progress = task.CurrentProgressPercentage;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', height: '1.2rem', mr: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '1.2rem',
|
||||
mr: 2,
|
||||
minWidth: '170px'
|
||||
}}
|
||||
>
|
||||
{progress != null ? (
|
||||
<>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<boolean>;
|
||||
|
||||
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 <Loading />;
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='mediaLibraryPage'
|
||||
title={globalize.translate('HeaderLibraries')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<Box className='content-primary'>
|
||||
<Stack spacing={3} mt={2}>
|
||||
<Stack direction='row' alignItems={'center'} spacing={1.5}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
onClick={showMediaLibraryCreator}
|
||||
>
|
||||
{globalize.translate('ButtonAddMediaLibrary')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onScanLibraries}
|
||||
startIcon={<RefreshIcon />}
|
||||
loading={librariesTask && librariesTask.State === TaskState.Running}
|
||||
loadingPosition='start'
|
||||
variant='outlined'
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
{(librariesTask && librariesTask.State == TaskState.Running) && (
|
||||
<TaskProgress task={librariesTask} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{virtualFolders?.map(virtualFolder => (
|
||||
<Grid
|
||||
key={virtualFolder?.ItemId}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
lg={2.4}
|
||||
>
|
||||
<LibraryCard
|
||||
virtualFolder={virtualFolder}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'LibrariesPage';
|
||||
@@ -32,7 +32,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<DialogContentText sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
setText(e.target.value);
|
||||
@@ -38,7 +48,7 @@ const InputDialog = ({ open, title, label, helperText, onClose, confirmButtonTex
|
||||
>
|
||||
{title && (
|
||||
<DialogTitle>
|
||||
{title}
|
||||
{title || ''}
|
||||
</DialogTitle>
|
||||
)}
|
||||
<DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user