feat: add quotas, advanced options, and toggles to collection request modal (#1742)
* feat: add quotas, advanced options, and toggles to collection request modal * fix: use correct requiredquota strings * refactor: clean up collection part request status logic * revert: undo changes to effect dependencies * fix(lang): tweak TV request modal request button strings * fix: don't try to fetch other users' quotas without MANAGE_USERS permpull/2170/head^2
parent
488874fc17
commit
af40212a73
@ -0,0 +1,454 @@
|
|||||||
|
import { DownloadIcon } from '@heroicons/react/outline';
|
||||||
|
import axios from 'axios';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
} from '../../../server/constants/media';
|
||||||
|
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||||
|
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||||
|
import { Permission } from '../../../server/lib/permissions';
|
||||||
|
import { Collection } from '../../../server/models/Collection';
|
||||||
|
import { useUser } from '../../hooks/useUser';
|
||||||
|
import globalMessages from '../../i18n/globalMessages';
|
||||||
|
import Alert from '../Common/Alert';
|
||||||
|
import Badge from '../Common/Badge';
|
||||||
|
import Modal from '../Common/Modal';
|
||||||
|
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||||
|
import QuotaDisplay from './QuotaDisplay';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
requestadmin: 'This request will be approved automatically.',
|
||||||
|
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||||
|
requesttitle: 'Request {title}',
|
||||||
|
request4ktitle: 'Request {title} in 4K',
|
||||||
|
requesterror: 'Something went wrong while submitting the request.',
|
||||||
|
selectmovies: 'Select Movie(s)',
|
||||||
|
requestmovies: 'Request {count} {count, plural, one {Movie} other {Movies}}',
|
||||||
|
requestmovies4k:
|
||||||
|
'Request {count} {count, plural, one {Movie} other {Movies}} in 4K',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
tmdbId: number;
|
||||||
|
is4k?: boolean;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionRequestModal: React.FC<RequestModalProps> = ({
|
||||||
|
onCancel,
|
||||||
|
onComplete,
|
||||||
|
tmdbId,
|
||||||
|
onUpdating,
|
||||||
|
is4k = false,
|
||||||
|
}) => {
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
const [requestOverrides, setRequestOverrides] =
|
||||||
|
useState<RequestOverrides | null>(null);
|
||||||
|
const [selectedParts, setSelectedParts] = useState<number[]>([]);
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const { data, error } = useSWR<Collection>(`/api/v1/collection/${tmdbId}`, {
|
||||||
|
revalidateOnMount: true,
|
||||||
|
});
|
||||||
|
const intl = useIntl();
|
||||||
|
const { user, hasPermission } = useUser();
|
||||||
|
const { data: quota } = useSWR<QuotaResponse>(
|
||||||
|
user &&
|
||||||
|
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
||||||
|
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentlyRemaining =
|
||||||
|
(quota?.movie.remaining ?? 0) - selectedParts.length;
|
||||||
|
|
||||||
|
const getAllParts = (): number[] => {
|
||||||
|
return (data?.parts ?? []).map((part) => part.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllRequestedParts = (): number[] => {
|
||||||
|
const requestedParts = (data?.parts ?? []).reduce(
|
||||||
|
(requestedParts, part) => {
|
||||||
|
return [
|
||||||
|
...requestedParts,
|
||||||
|
...(part.mediaInfo?.requests ?? [])
|
||||||
|
.filter(
|
||||||
|
(request) =>
|
||||||
|
request.is4k === is4k &&
|
||||||
|
request.status !== MediaRequestStatus.DECLINED
|
||||||
|
)
|
||||||
|
.map((part) => part.id),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[] as number[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableParts = (data?.parts ?? [])
|
||||||
|
.filter(
|
||||||
|
(part) =>
|
||||||
|
part.mediaInfo &&
|
||||||
|
(part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.AVAILABLE ||
|
||||||
|
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PROCESSING) &&
|
||||||
|
!requestedParts.includes(part.id)
|
||||||
|
)
|
||||||
|
.map((part) => part.id);
|
||||||
|
|
||||||
|
return [...requestedParts, ...availableParts];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelectedPart = (tmdbId: number): boolean =>
|
||||||
|
selectedParts.includes(tmdbId);
|
||||||
|
|
||||||
|
const togglePart = (tmdbId: number): void => {
|
||||||
|
// If this part already has a pending request, don't allow it to be toggled
|
||||||
|
if (getAllRequestedParts().includes(tmdbId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are no more remaining requests available, block toggle
|
||||||
|
if (
|
||||||
|
quota?.movie.limit &&
|
||||||
|
currentlyRemaining <= 0 &&
|
||||||
|
!isSelectedPart(tmdbId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedParts.includes(tmdbId)) {
|
||||||
|
setSelectedParts((parts) => parts.filter((partId) => partId !== tmdbId));
|
||||||
|
} else {
|
||||||
|
setSelectedParts((parts) => [...parts, tmdbId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unrequestedParts = getAllParts().filter(
|
||||||
|
(tmdbId) => !getAllRequestedParts().includes(tmdbId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleAllParts = (): void => {
|
||||||
|
// If the user has a quota and not enough requests for all parts, block toggleAllParts
|
||||||
|
if (
|
||||||
|
quota?.movie.limit &&
|
||||||
|
(quota?.movie.remaining ?? 0) < unrequestedParts.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
selectedParts.length >= 0 &&
|
||||||
|
selectedParts.length < unrequestedParts.length
|
||||||
|
) {
|
||||||
|
setSelectedParts(unrequestedParts);
|
||||||
|
} else {
|
||||||
|
setSelectedParts([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllParts = (): boolean => {
|
||||||
|
if (!data) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedParts.length ===
|
||||||
|
getAllParts().filter((part) => !getAllRequestedParts().includes(part))
|
||||||
|
.length
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPartRequest = (tmdbId: number): MediaRequest | undefined => {
|
||||||
|
const part = (data?.parts ?? []).find((part) => part.id === tmdbId);
|
||||||
|
|
||||||
|
return (part?.mediaInfo?.requests ?? []).find(
|
||||||
|
(request) =>
|
||||||
|
request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onUpdating) {
|
||||||
|
onUpdating(isUpdating);
|
||||||
|
}
|
||||||
|
}, [isUpdating, onUpdating]);
|
||||||
|
|
||||||
|
const sendRequest = useCallback(async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let overrideParams = {};
|
||||||
|
if (requestOverrides) {
|
||||||
|
overrideParams = {
|
||||||
|
serverId: requestOverrides.server,
|
||||||
|
profileId: requestOverrides.profile,
|
||||||
|
rootFolder: requestOverrides.folder,
|
||||||
|
userId: requestOverrides.user?.id,
|
||||||
|
tags: requestOverrides.tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(
|
||||||
|
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
|
||||||
|
).map(async (part) => {
|
||||||
|
await axios.post<MediaRequest>('/api/v1/request', {
|
||||||
|
mediaId: part.id,
|
||||||
|
mediaType: 'movie',
|
||||||
|
is4k,
|
||||||
|
...overrideParams,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete(
|
||||||
|
selectedParts.length === (data?.parts ?? []).length
|
||||||
|
? MediaStatus.UNKNOWN
|
||||||
|
: MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.requestSuccess, {
|
||||||
|
title: data?.name,
|
||||||
|
strong: function strong(msg) {
|
||||||
|
return <strong>{msg}</strong>;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'success', autoDismiss: true }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.requesterror), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
}, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]);
|
||||||
|
|
||||||
|
const hasAutoApprove = hasPermission(
|
||||||
|
[
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
|
||||||
|
is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE,
|
||||||
|
],
|
||||||
|
{ type: 'or' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
loading={(!data && !error) || !quota}
|
||||||
|
backgroundClickable
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={sendRequest}
|
||||||
|
title={intl.formatMessage(
|
||||||
|
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||||
|
{ title: data?.name }
|
||||||
|
)}
|
||||||
|
okText={
|
||||||
|
isUpdating
|
||||||
|
? intl.formatMessage(globalMessages.requesting)
|
||||||
|
: selectedParts.length === 0
|
||||||
|
? intl.formatMessage(messages.selectmovies)
|
||||||
|
: intl.formatMessage(
|
||||||
|
is4k ? messages.requestmovies4k : messages.requestmovies,
|
||||||
|
{
|
||||||
|
count: selectedParts.length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
okDisabled={selectedParts.length === 0}
|
||||||
|
okButtonType={'primary'}
|
||||||
|
iconSvg={<DownloadIcon />}
|
||||||
|
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||||
|
>
|
||||||
|
{hasAutoApprove && !quota?.movie.restricted && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Alert
|
||||||
|
title={intl.formatMessage(messages.requestadmin)}
|
||||||
|
type="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(quota?.movie.limit ?? 0) > 0 && (
|
||||||
|
<QuotaDisplay
|
||||||
|
mediaType="movie"
|
||||||
|
quota={quota?.movie}
|
||||||
|
remaining={currentlyRemaining}
|
||||||
|
userOverride={
|
||||||
|
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
||||||
|
? requestOverrides?.user?.id
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="-mx-4 sm:mx-0">
|
||||||
|
<div className="inline-block min-w-full py-2 align-middle">
|
||||||
|
<div className="overflow-hidden shadow sm:rounded-lg">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-16 px-4 py-3 bg-gray-500">
|
||||||
|
<span
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-checked={isAllParts()}
|
||||||
|
onClick={() => toggleAllParts()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
toggleAllParts();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${
|
||||||
|
quota?.movie.limit &&
|
||||||
|
(quota.movie.remaining ?? 0) < unrequestedParts.length
|
||||||
|
? 'opacity-50'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
isAllParts() ? 'bg-indigo-500' : 'bg-gray-800'
|
||||||
|
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
isAllParts() ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:ring group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="px-1 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||||
|
{intl.formatMessage(globalMessages.movie)}
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||||
|
{intl.formatMessage(globalMessages.status)}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-gray-600 divide-y divide-gray-700">
|
||||||
|
{data?.parts.map((part) => {
|
||||||
|
const partRequest = getPartRequest(part.id);
|
||||||
|
const partMedia =
|
||||||
|
part.mediaInfo &&
|
||||||
|
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
|
||||||
|
MediaStatus.UNKNOWN
|
||||||
|
? part.mediaInfo
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={`part-${part.id}`}>
|
||||||
|
<td className="px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-checked={
|
||||||
|
!!partMedia || isSelectedPart(part.id)
|
||||||
|
}
|
||||||
|
onClick={() => togglePart(part.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
togglePart(part.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||||
|
!!partMedia ||
|
||||||
|
partRequest ||
|
||||||
|
(quota?.movie.limit &&
|
||||||
|
currentlyRemaining <= 0 &&
|
||||||
|
!isSelectedPart(part.id))
|
||||||
|
? 'opacity-50'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
!!partMedia ||
|
||||||
|
partRequest ||
|
||||||
|
isSelectedPart(part.id)
|
||||||
|
? 'bg-indigo-500'
|
||||||
|
: 'bg-gray-800'
|
||||||
|
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`${
|
||||||
|
!!partMedia ||
|
||||||
|
partRequest ||
|
||||||
|
isSelectedPart(part.id)
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'
|
||||||
|
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:ring group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 whitespace-nowrap">
|
||||||
|
{part.title}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
|
||||||
|
{!partMedia && !partRequest && (
|
||||||
|
<Badge>
|
||||||
|
{intl.formatMessage(globalMessages.notrequested)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!partMedia &&
|
||||||
|
partRequest?.status ===
|
||||||
|
MediaRequestStatus.PENDING && (
|
||||||
|
<Badge badgeType="warning">
|
||||||
|
{intl.formatMessage(globalMessages.pending)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{((!partMedia &&
|
||||||
|
partRequest?.status ===
|
||||||
|
MediaRequestStatus.APPROVED) ||
|
||||||
|
partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.PROCESSING) && (
|
||||||
|
<Badge badgeType="primary">
|
||||||
|
{intl.formatMessage(globalMessages.requested)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{partMedia?.[is4k ? 'status4k' : 'status'] ===
|
||||||
|
MediaStatus.AVAILABLE && (
|
||||||
|
<Badge badgeType="success">
|
||||||
|
{intl.formatMessage(globalMessages.available)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||||
|
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||||
|
<AdvancedRequester
|
||||||
|
type="movie"
|
||||||
|
is4k={is4k}
|
||||||
|
onChange={(overrides) => {
|
||||||
|
setRequestOverrides(overrides);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionRequestModal;
|
Loading…
Reference in new issue