mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
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 perm
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
|
import { DownloadIcon } from '@heroicons/react/outline';
|
||||||
import axios from 'axios';
|
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { MediaStatus } from '../../../server/constants/media';
|
import { MediaStatus } from '../../../server/constants/media';
|
||||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
|
||||||
import type { Collection } from '../../../server/models/Collection';
|
import type { Collection } from '../../../server/models/Collection';
|
||||||
import useSettings from '../../hooks/useSettings';
|
import useSettings from '../../hooks/useSettings';
|
||||||
import { Permission, useUser } from '../../hooks/useUser';
|
import { Permission, useUser } from '../../hooks/useUser';
|
||||||
@@ -17,23 +14,17 @@ import Error from '../../pages/_error';
|
|||||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||||
import CachedImage from '../Common/CachedImage';
|
import CachedImage from '../Common/CachedImage';
|
||||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||||
import Modal from '../Common/Modal';
|
|
||||||
import PageTitle from '../Common/PageTitle';
|
import PageTitle from '../Common/PageTitle';
|
||||||
|
import RequestModal from '../RequestModal';
|
||||||
import Slider from '../Slider';
|
import Slider from '../Slider';
|
||||||
import StatusBadge from '../StatusBadge';
|
import StatusBadge from '../StatusBadge';
|
||||||
import TitleCard from '../TitleCard';
|
import TitleCard from '../TitleCard';
|
||||||
import Transition from '../Transition';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
numberofmovies: '{count} Movies',
|
numberofmovies: '{count} Movies',
|
||||||
requestcollection: 'Request Collection',
|
requestcollection: 'Request Collection',
|
||||||
requestswillbecreated:
|
|
||||||
'The following titles will have requests created for them:',
|
|
||||||
requestcollection4k: 'Request Collection in 4K',
|
requestcollection4k: 'Request Collection in 4K',
|
||||||
requestswillbecreated4k:
|
|
||||||
'The following titles will have 4K requests created for them:',
|
|
||||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
interface CollectionDetailsProps {
|
interface CollectionDetailsProps {
|
||||||
@@ -46,10 +37,8 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { addToast } = useToasts();
|
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
const [requestModal, setRequestModal] = useState(false);
|
const [requestModal, setRequestModal] = useState(false);
|
||||||
const [isRequesting, setRequesting] = useState(false);
|
|
||||||
const [is4k, setIs4k] = useState(false);
|
const [is4k, setIs4k] = useState(false);
|
||||||
|
|
||||||
const { data, error, revalidate } = useSWR<Collection>(
|
const { data, error, revalidate } = useSWR<Collection>(
|
||||||
@@ -124,48 +113,6 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||||
).length > 0;
|
).length > 0;
|
||||||
|
|
||||||
const requestableParts = data.parts.filter(
|
|
||||||
(part) =>
|
|
||||||
!part.mediaInfo ||
|
|
||||||
part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestBundle = async () => {
|
|
||||||
try {
|
|
||||||
setRequesting(true);
|
|
||||||
await Promise.all(
|
|
||||||
requestableParts.map(async (part) => {
|
|
||||||
await axios.post<MediaRequest>('/api/v1/request', {
|
|
||||||
mediaId: part.id,
|
|
||||||
mediaType: 'movie',
|
|
||||||
is4k,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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('Something went wrong requesting the collection.', {
|
|
||||||
appearance: 'error',
|
|
||||||
autoDismiss: true,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setRequesting(false);
|
|
||||||
setRequestModal(false);
|
|
||||||
revalidate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const collectionAttributes: React.ReactNode[] = [];
|
const collectionAttributes: React.ReactNode[] = [];
|
||||||
|
|
||||||
collectionAttributes.push(
|
collectionAttributes.push(
|
||||||
@@ -229,53 +176,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PageTitle title={data.name} />
|
<PageTitle title={data.name} />
|
||||||
<Transition
|
<RequestModal
|
||||||
enter="opacity-0 transition duration-300"
|
tmdbId={data.id}
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="opacity-100 transition duration-300"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
show={requestModal}
|
show={requestModal}
|
||||||
>
|
type="collection"
|
||||||
<Modal
|
is4k={is4k}
|
||||||
onOk={() => requestBundle()}
|
onComplete={() => {
|
||||||
okText={
|
revalidate();
|
||||||
isRequesting
|
setRequestModal(false);
|
||||||
? intl.formatMessage(globalMessages.requesting)
|
}}
|
||||||
: intl.formatMessage(
|
|
||||||
is4k ? globalMessages.request4k : globalMessages.request
|
|
||||||
)
|
|
||||||
}
|
|
||||||
okDisabled={isRequesting}
|
|
||||||
okButtonType="primary"
|
|
||||||
onCancel={() => setRequestModal(false)}
|
onCancel={() => setRequestModal(false)}
|
||||||
title={intl.formatMessage(
|
/>
|
||||||
is4k ? messages.requestcollection4k : messages.requestcollection
|
|
||||||
)}
|
|
||||||
iconSvg={<DuplicateIcon />}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{intl.formatMessage(
|
|
||||||
is4k
|
|
||||||
? messages.requestswillbecreated4k
|
|
||||||
: messages.requestswillbecreated
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<ul className="py-4 pl-8 list-disc">
|
|
||||||
{data.parts
|
|
||||||
.filter(
|
|
||||||
(part) =>
|
|
||||||
!part.mediaInfo ||
|
|
||||||
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
|
|
||||||
MediaStatus.UNKNOWN
|
|
||||||
)
|
|
||||||
.map((part) => (
|
|
||||||
<li key={`request-part-${part.id}`}>{part.title}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Modal>
|
|
||||||
</Transition>
|
|
||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
|
@@ -283,7 +283,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center mb-2 font-bold tracking-wider">
|
<div className="flex items-center mt-4 mb-2 font-bold tracking-wider">
|
||||||
<AdjustmentsIcon className="w-5 h-5 mr-1.5" />
|
<AdjustmentsIcon className="w-5 h-5 mr-1.5" />
|
||||||
{intl.formatMessage(messages.advancedoptions)}
|
{intl.formatMessage(messages.advancedoptions)}
|
||||||
</div>
|
</div>
|
||||||
|
454
src/components/RequestModal/CollectionRequestModal.tsx
Normal file
454
src/components/RequestModal/CollectionRequestModal.tsx
Normal file
@@ -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;
|
@@ -60,7 +60,10 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { user, hasPermission } = useUser();
|
const { user, hasPermission } = useUser();
|
||||||
const { data: quota } = useSWR<QuotaResponse>(
|
const { data: quota } = useSWR<QuotaResponse>(
|
||||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
user &&
|
||||||
|
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
||||||
|
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -244,7 +247,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
})}
|
})}
|
||||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||||
<div className="mt-4">
|
|
||||||
<AdvancedRequester
|
<AdvancedRequester
|
||||||
type="movie"
|
type="movie"
|
||||||
is4k={is4k}
|
is4k={is4k}
|
||||||
@@ -259,7 +261,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
setRequestOverrides(overrides);
|
setRequestOverrides(overrides);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
@@ -99,8 +99,8 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
{intl.formatMessage(
|
{intl.formatMessage(
|
||||||
userOverride
|
userOverride
|
||||||
? messages.requiredquota
|
? messages.requiredquotaUser
|
||||||
: messages.requiredquotaUser,
|
: messages.requiredquota,
|
||||||
{
|
{
|
||||||
seasons: overLimit,
|
seasons: overLimit,
|
||||||
strong: function strong(msg) {
|
strong: function strong(msg) {
|
||||||
|
@@ -36,7 +36,8 @@ const messages = defineMessages({
|
|||||||
requestfrom: "{username}'s request is pending approval.",
|
requestfrom: "{username}'s request is pending approval.",
|
||||||
requestseasons:
|
requestseasons:
|
||||||
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
|
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
|
||||||
requestall: 'Request All Seasons',
|
requestseasons4k:
|
||||||
|
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K',
|
||||||
alreadyrequested: 'Already Requested',
|
alreadyrequested: 'Already Requested',
|
||||||
selectseason: 'Select Season(s)',
|
selectseason: 'Select Season(s)',
|
||||||
season: 'Season',
|
season: 'Season',
|
||||||
@@ -88,7 +89,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
});
|
});
|
||||||
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
|
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
|
||||||
const { data: quota } = useSWR<QuotaResponse>(
|
const { data: quota } = useSWR<QuotaResponse>(
|
||||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
user &&
|
||||||
|
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
|
||||||
|
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentlyRemaining =
|
const currentlyRemaining =
|
||||||
@@ -387,12 +391,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
: getAllRequestedSeasons().length >= getAllSeasons().length
|
: getAllRequestedSeasons().length >= getAllSeasons().length
|
||||||
? intl.formatMessage(messages.alreadyrequested)
|
? intl.formatMessage(messages.alreadyrequested)
|
||||||
: !settings.currentSettings.partialRequestsEnabled
|
: !settings.currentSettings.partialRequestsEnabled
|
||||||
? intl.formatMessage(messages.requestall)
|
? intl.formatMessage(
|
||||||
|
is4k ? globalMessages.request4k : globalMessages.request
|
||||||
|
)
|
||||||
: selectedSeasons.length === 0
|
: selectedSeasons.length === 0
|
||||||
? intl.formatMessage(messages.selectseason)
|
? intl.formatMessage(messages.selectseason)
|
||||||
: intl.formatMessage(messages.requestseasons, {
|
: intl.formatMessage(
|
||||||
|
is4k ? messages.requestseasons4k : messages.requestseasons,
|
||||||
|
{
|
||||||
seasonCount: selectedSeasons.length,
|
seasonCount: selectedSeasons.length,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
okDisabled={
|
okDisabled={
|
||||||
editRequest
|
editRequest
|
||||||
@@ -440,7 +449,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
!(
|
!(
|
||||||
quota?.tv.limit &&
|
quota?.tv.limit &&
|
||||||
!settings.currentSettings.partialRequestsEnabled &&
|
!settings.currentSettings.partialRequestsEnabled &&
|
||||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
||||||
) &&
|
) &&
|
||||||
getAllRequestedSeasons().length < getAllSeasons().length &&
|
getAllRequestedSeasons().length < getAllSeasons().length &&
|
||||||
!editRequest && (
|
!editRequest && (
|
||||||
@@ -457,7 +466,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
quota={quota?.tv}
|
quota={quota?.tv}
|
||||||
remaining={
|
remaining={
|
||||||
!settings.currentSettings.partialRequestsEnabled &&
|
!settings.currentSettings.partialRequestsEnabled &&
|
||||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
||||||
? 0
|
? 0
|
||||||
: currentlyRemaining
|
: currentlyRemaining
|
||||||
}
|
}
|
||||||
@@ -468,7 +477,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
}
|
}
|
||||||
overLimit={
|
overLimit={
|
||||||
!settings.currentSettings.partialRequestsEnabled &&
|
!settings.currentSettings.partialRequestsEnabled &&
|
||||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
unrequestedSeasons.length > (quota?.tv.remaining ?? 0)
|
||||||
? unrequestedSeasons.length
|
? unrequestedSeasons.length
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -667,7 +676,6 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||||
<div className="mt-4">
|
|
||||||
<AdvancedRequester
|
<AdvancedRequester
|
||||||
type="tv"
|
type="tv"
|
||||||
is4k={is4k}
|
is4k={is4k}
|
||||||
@@ -688,7 +696,6 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import MovieRequestModal from './MovieRequestModal';
|
|
||||||
import type { MediaStatus } from '../../../server/constants/media';
|
import type { MediaStatus } from '../../../server/constants/media';
|
||||||
import TvRequestModal from './TvRequestModal';
|
|
||||||
import Transition from '../Transition';
|
|
||||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||||
|
import Transition from '../Transition';
|
||||||
|
import CollectionRequestModal from './CollectionRequestModal';
|
||||||
|
import MovieRequestModal from './MovieRequestModal';
|
||||||
|
import TvRequestModal from './TvRequestModal';
|
||||||
|
|
||||||
interface RequestModalProps {
|
interface RequestModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv' | 'collection';
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: MediaRequest;
|
||||||
@@ -26,29 +27,6 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
|||||||
onUpdating,
|
onUpdating,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
if (type === 'tv') {
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
enter="transition opacity-0 duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="transition opacity-100 duration-300"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
show={show}
|
|
||||||
>
|
|
||||||
<TvRequestModal
|
|
||||||
onComplete={onComplete}
|
|
||||||
onCancel={onCancel}
|
|
||||||
tmdbId={tmdbId}
|
|
||||||
onUpdating={onUpdating}
|
|
||||||
is4k={is4k}
|
|
||||||
editRequest={editRequest}
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
enter="transition opacity-0 duration-300"
|
enter="transition opacity-0 duration-300"
|
||||||
@@ -59,6 +37,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
|||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
show={show}
|
show={show}
|
||||||
>
|
>
|
||||||
|
{type === 'movie' ? (
|
||||||
<MovieRequestModal
|
<MovieRequestModal
|
||||||
onComplete={onComplete}
|
onComplete={onComplete}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
@@ -67,6 +46,24 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
|||||||
is4k={is4k}
|
is4k={is4k}
|
||||||
editRequest={editRequest}
|
editRequest={editRequest}
|
||||||
/>
|
/>
|
||||||
|
) : type === 'tv' ? (
|
||||||
|
<TvRequestModal
|
||||||
|
onComplete={onComplete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
onUpdating={onUpdating}
|
||||||
|
is4k={is4k}
|
||||||
|
editRequest={editRequest}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CollectionRequestModal
|
||||||
|
onComplete={onComplete}
|
||||||
|
onCancel={onCancel}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
onUpdating={onUpdating}
|
||||||
|
is4k={is4k}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Transition>
|
</Transition>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -2,11 +2,8 @@
|
|||||||
"components.AppDataWarning.dockerVolumeMissingDescription": "The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.",
|
"components.AppDataWarning.dockerVolumeMissingDescription": "The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.",
|
||||||
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
||||||
"components.CollectionDetails.overview": "Overview",
|
"components.CollectionDetails.overview": "Overview",
|
||||||
"components.CollectionDetails.requestSuccess": "<strong>{title}</strong> requested successfully!",
|
|
||||||
"components.CollectionDetails.requestcollection": "Request Collection",
|
"components.CollectionDetails.requestcollection": "Request Collection",
|
||||||
"components.CollectionDetails.requestcollection4k": "Request Collection in 4K",
|
"components.CollectionDetails.requestcollection4k": "Request Collection in 4K",
|
||||||
"components.CollectionDetails.requestswillbecreated": "The following titles will have requests created for them:",
|
|
||||||
"components.CollectionDetails.requestswillbecreated4k": "The following titles will have 4K requests created for them:",
|
|
||||||
"components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies",
|
"components.Discover.DiscoverMovieGenre.genreMovies": "{genre} Movies",
|
||||||
"components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies",
|
"components.Discover.DiscoverMovieLanguage.languageMovies": "{language} Movies",
|
||||||
"components.Discover.DiscoverNetwork.networkSeries": "{network} Series",
|
"components.Discover.DiscoverNetwork.networkSeries": "{network} Series",
|
||||||
@@ -336,15 +333,18 @@
|
|||||||
"components.RequestModal.requestCancel": "Request for <strong>{title}</strong> canceled.",
|
"components.RequestModal.requestCancel": "Request for <strong>{title}</strong> canceled.",
|
||||||
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested successfully!",
|
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested successfully!",
|
||||||
"components.RequestModal.requestadmin": "This request will be approved automatically.",
|
"components.RequestModal.requestadmin": "This request will be approved automatically.",
|
||||||
"components.RequestModal.requestall": "Request All Seasons",
|
|
||||||
"components.RequestModal.requestcancelled": "Request for <strong>{title}</strong> canceled.",
|
"components.RequestModal.requestcancelled": "Request for <strong>{title}</strong> canceled.",
|
||||||
"components.RequestModal.requestedited": "Request for <strong>{title}</strong> edited successfully!",
|
"components.RequestModal.requestedited": "Request for <strong>{title}</strong> edited successfully!",
|
||||||
"components.RequestModal.requesterror": "Something went wrong while submitting the request.",
|
"components.RequestModal.requesterror": "Something went wrong while submitting the request.",
|
||||||
"components.RequestModal.requestfrom": "{username}'s request is pending approval.",
|
"components.RequestModal.requestfrom": "{username}'s request is pending approval.",
|
||||||
|
"components.RequestModal.requestmovies": "Request {count} {count, plural, one {Movie} other {Movies}}",
|
||||||
|
"components.RequestModal.requestmovies4k": "Request {count} {count, plural, one {Movie} other {Movies}} in 4K",
|
||||||
"components.RequestModal.requestseasons": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}",
|
"components.RequestModal.requestseasons": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}",
|
||||||
|
"components.RequestModal.requestseasons4k": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}} in 4K",
|
||||||
"components.RequestModal.requesttitle": "Request {title}",
|
"components.RequestModal.requesttitle": "Request {title}",
|
||||||
"components.RequestModal.season": "Season",
|
"components.RequestModal.season": "Season",
|
||||||
"components.RequestModal.seasonnumber": "Season {number}",
|
"components.RequestModal.seasonnumber": "Season {number}",
|
||||||
|
"components.RequestModal.selectmovies": "Select Movie(s)",
|
||||||
"components.RequestModal.selectseason": "Select Season(s)",
|
"components.RequestModal.selectseason": "Select Season(s)",
|
||||||
"components.ResetPassword.confirmpassword": "Confirm Password",
|
"components.ResetPassword.confirmpassword": "Confirm Password",
|
||||||
"components.ResetPassword.email": "Email Address",
|
"components.ResetPassword.email": "Email Address",
|
||||||
|
Reference in New Issue
Block a user