diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index b65df670c..5e95f03b1 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -1,24 +1,38 @@ -import React, { useContext, useEffect } from 'react'; -import { useInView } from 'react-intersection-observer'; -import type { MediaRequest } from '../../../server/entity/MediaRequest'; -import type { TvDetails } from '../../../server/models/Tv'; -import type { MovieDetails } from '../../../server/models/Movie'; -import useSWR from 'swr'; -import { LanguageContext } from '../../context/LanguageContext'; -import { MediaRequestStatus } from '../../../server/constants/media'; -import Badge from '../Common/Badge'; -import { useUser, Permission } from '../../hooks/useUser'; +import { + CheckIcon, + PencilIcon, + RefreshIcon, + TrashIcon, + XIcon, +} from '@heroicons/react/solid'; import axios from 'axios'; -import Button from '../Common/Button'; -import { withProperties } from '../../utils/typeHelpers'; import Link from 'next/link'; +import React, { useEffect, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR, { mutate } from 'swr'; +import { + MediaRequestStatus, + MediaStatus, +} from '../../../server/constants/media'; +import type { MediaRequest } from '../../../server/entity/MediaRequest'; +import type { MovieDetails } from '../../../server/models/Movie'; +import type { TvDetails } from '../../../server/models/Tv'; +import { Permission, useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; +import { withProperties } from '../../utils/typeHelpers'; +import Badge from '../Common/Badge'; +import Button from '../Common/Button'; +import CachedImage from '../Common/CachedImage'; +import RequestModal from '../RequestModal'; import StatusBadge from '../StatusBadge'; const messages = defineMessages({ - seasons: 'Seasons', - all: 'All', + seasons: '{seasonCount, plural, one {Season} other {Seasons}}', + failedretry: 'Something went wrong while retrying the request.', + mediaerror: 'The associated title for this request is no longer available.', + deleterequest: 'Delete Request', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -27,7 +41,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const RequestCardPlaceholder: React.FC = () => { return ( -
+
@@ -35,6 +49,45 @@ const RequestCardPlaceholder: React.FC = () => { ); }; +interface RequestCardErrorProps { + mediaId?: number; +} + +const RequestCardError: React.FC = ({ mediaId }) => { + const { hasPermission } = useUser(); + const intl = useIntl(); + + const deleteRequest = async () => { + await axios.delete(`/api/v1/media/${mediaId}`); + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + }; + + return ( +
+
+
+
+
+ {intl.formatMessage(messages.mediaerror)} +
+ {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( + + )} +
+
+
+
+ ); +}; + interface RequestCardProps { request: MediaRequest; onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; @@ -45,19 +98,21 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { triggerOnce: true, }); const intl = useIntl(); - const { hasPermission } = useUser(); - const { locale } = useContext(LanguageContext); + const { user, hasPermission } = useUser(); + const { addToast } = useToasts(); + const [isRetrying, setRetrying] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? `${url}` : null ); const { data: requestData, error: requestError, - revalidate, + mutate: revalidate, } = useSWR(`/api/v1/request/${request.id}`, { initialData: request, }); @@ -70,6 +125,30 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { } }; + const deleteRequest = async () => { + await axios.delete(`/api/v1/request/${request.id}`); + mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + }; + + const retryRequest = async () => { + setRetrying(true); + + try { + const response = await axios.post(`/api/v1/request/${request.id}/retry`); + + if (response) { + revalidate(); + } + } catch (e) { + addToast(intl.formatMessage(messages.failedretry), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setRetrying(false); + } + }; + useEffect(() => { if (title && onTitleData) { onTitleData(request.id, title); @@ -85,157 +164,251 @@ const RequestCard: React.FC = ({ request, onTitleData }) => { } if (!requestData && !requestError) { - return ; + return ; } if (!title || !requestData) { - return ; + return ; } return ( -
-
-

- - {isMovie(title) ? title.title : title.name} - -

- - - + setShowEditModal(false)} + onComplete={() => { + revalidate(); + setShowEditModal(false); + }} + /> +
+ {title.backdropPath && ( +
+ - - {requestData.requestedBy.displayName} - - - - {requestData.media.status && ( -
- 0 - } +
)} - {request.seasons.length > 0 && ( -
- {intl.formatMessage(messages.seasons)} - {!isMovie(title) && - title.seasons.filter((season) => season.seasonNumber !== 0) - .length === request.seasons.length ? ( - - {intl.formatMessage(messages.all)} - - ) : ( -
- {request.seasons.map((season) => ( - - {season.seasonNumber} - - ))} -
+
+
+ {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( + 0, + 4 )}
- )} - {requestData.status === MediaRequestStatus.PENDING && - hasPermission(Permission.MANAGE_REQUESTS) && ( -
- + + + {isMovie(title) ? title.title : title.name} + + + {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) && ( + + )} + {!isMovie(title) && request.seasons.length > 0 && ( +
+ + {intl.formatMessage(messages.seasons, { + seasonCount: + title.seasons.filter((season) => season.seasonNumber !== 0) + .length === request.seasons.length + ? 0 + : request.seasons.length, + })} + + {title.seasons.filter((season) => season.seasonNumber !== 0) + .length === request.seasons.length ? ( + + {intl.formatMessage(globalMessages.all)} + + ) : ( +
+ {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
+ )} +
+ )} +
+ + {intl.formatMessage(globalMessages.status)} + + {requestData.status === MediaRequestStatus.DECLINED ? ( + + {intl.formatMessage(globalMessages.declined)} + + ) : requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN ? ( + + {intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + tmdbId={requestData.media.tmdbId} + mediaType={requestData.type} + plexUrl={ + requestData.media[ + requestData.is4k ? 'mediaUrl4k' : 'mediaUrl' + ] + } + /> + )} +
+
+ {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN && + requestData.status !== MediaRequestStatus.DECLINED && + hasPermission(Permission.MANAGE_REQUESTS) && ( - - + )} + {requestData.status === MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( + <> + + + + )} + {requestData.status === MediaRequestStatus.PENDING && + !hasPermission(Permission.MANAGE_REQUESTS) && + requestData.requestedBy.id === user?.id && + (requestData.type === 'tv' || + hasPermission(Permission.REQUEST_ADVANCED)) && ( + + )} + {requestData.status === MediaRequestStatus.PENDING && + !hasPermission(Permission.MANAGE_REQUESTS) && + requestData.requestedBy.id === user?.id && ( - -
- )} -
-
+ )} +
+
- + + +
-
+ ); }; diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 372725588..bb0cbd8d4 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -1,13 +1,15 @@ +import { + CheckIcon, + PencilIcon, + RefreshIcon, + TrashIcon, + XIcon, +} from '@heroicons/react/solid'; import axios from 'axios'; import Link from 'next/link'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { useInView } from 'react-intersection-observer'; -import { - defineMessages, - FormattedDate, - FormattedRelativeTime, - useIntl, -} from 'react-intl'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import { @@ -17,25 +19,70 @@ import { import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MovieDetails } from '../../../../server/models/Movie'; import type { TvDetails } from '../../../../server/models/Tv'; -import { LanguageContext } from '../../../context/LanguageContext'; import { Permission, useUser } from '../../../hooks/useUser'; import globalMessages from '../../../i18n/globalMessages'; import Badge from '../../Common/Badge'; import Button from '../../Common/Button'; -import Table from '../../Common/Table'; +import CachedImage from '../../Common/CachedImage'; +import ConfirmButton from '../../Common/ConfirmButton'; import RequestModal from '../../RequestModal'; import StatusBadge from '../../StatusBadge'; const messages = defineMessages({ - seasons: 'Seasons', - notavailable: 'N/A', + seasons: '{seasonCount, plural, one {Season} other {Seasons}}', failedretry: 'Something went wrong while retrying the request.', + requested: 'Requested', + requesteddate: 'Requested', + modified: 'Modified', + modifieduserdate: '{date} by {user}', + mediaerror: 'The associated title for this request is no longer available.', + editrequest: 'Edit Request', + deleterequest: 'Delete Request', + cancelRequest: 'Cancel Request', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; +interface RequestItemErroProps { + mediaId?: number; + revalidateList: () => void; +} + +const RequestItemError: React.FC = ({ + mediaId, + revalidateList, +}) => { + const intl = useIntl(); + const { hasPermission } = useUser(); + + const deleteRequest = async () => { + await axios.delete(`/api/v1/media/${mediaId}`); + revalidateList(); + }; + + return ( +
+ + {intl.formatMessage(messages.mediaerror)} + + {hasPermission(Permission.MANAGE_REQUESTS) && mediaId && ( +
+ +
+ )} +
+ ); +}; + interface RequestItemProps { request: MediaRequest; revalidateList: () => void; @@ -50,23 +97,21 @@ const RequestItem: React.FC = ({ }); const { addToast } = useToasts(); const intl = useIntl(); - const { hasPermission } = useUser(); + const { user, hasPermission } = useUser(); const [showEditModal, setShowEditModal] = useState(false); - const { locale } = useContext(LanguageContext); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}?language=${locale}` : null + inView ? url : null + ); + const { data: requestData, mutate: revalidate } = useSWR( + `/api/v1/request/${request.id}`, + { + initialData: request, + } ); - const { - data: requestData, - revalidate, - mutate, - } = useSWR(`/api/v1/request/${request.id}`, { - initialData: request, - }); const [isRetrying, setRetrying] = useState(false); @@ -89,7 +134,7 @@ const RequestItem: React.FC = ({ try { const result = await axios.post(`/api/v1/request/${request.id}/retry`); - mutate(result.data); + revalidate(result.data); } catch (e) { addToast(intl.formatMessage(messages.failedretry), { autoDismiss: true, @@ -102,22 +147,24 @@ const RequestItem: React.FC = ({ if (!title && !error) { return ( - - - +
); } if (!title || !requestData) { return ( - - - + ); } return ( - + <> = ({ setShowEditModal(false); }} /> - -
- - - - - -
+
+ {title.backdropPath && ( +
+ +
+
+ )} +
+
= ({ : `/tv/${requestData.media.tmdbId}` } > - - {isMovie(title) ? title.title : title.name} - - - - - + - - {requestData.requestedBy.displayName} - - {requestData.seasons.length > 0 && ( -
- - {intl.formatMessage(messages.seasons)} - - {requestData.seasons.map((season) => ( - - {season.seasonNumber} +
+
+ {(isMovie(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)} +
+ + + {isMovie(title) ? title.title : title.name} + + + {!isMovie(title) && request.seasons.length > 0 && ( +
+ + {intl.formatMessage(messages.seasons, { + seasonCount: + title.seasons.filter( + (season) => season.seasonNumber !== 0 + ).length === request.seasons.length + ? 0 + : request.seasons.length, + })} - ))} + {title.seasons.filter((season) => season.seasonNumber !== 0) + .length === request.seasons.length ? ( + + {intl.formatMessage(globalMessages.all)} + + ) : ( +
+ {request.seasons.map((season) => ( + + {season.seasonNumber} + + ))} +
+ )} +
+ )} +
+
+
+
+ + {intl.formatMessage(globalMessages.status)} + + {requestData.status === MediaRequestStatus.DECLINED ? ( + + {intl.formatMessage(globalMessages.declined)} + + ) : requestData.media[ + requestData.is4k ? 'status4k' : 'status' + ] === MediaStatus.UNKNOWN ? ( + + {intl.formatMessage(globalMessages.failed)} + + ) : ( + 0 + } + is4k={requestData.is4k} + tmdbId={requestData.media.tmdbId} + mediaType={requestData.type} + plexUrl={ + requestData.media[ + requestData.is4k ? 'mediaUrl4k' : 'mediaUrl' + ] + } + /> + )} +
+
+ {hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) ? ( + <> + + {intl.formatMessage(messages.requested)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.requestedBy.displayName} + + + + ), + })} + + + ) : ( + <> + + {intl.formatMessage(messages.requesteddate)} + + + + + + )} +
+ {requestData.modifiedBy && ( +
+ + {intl.formatMessage(messages.modified)} + + + {intl.formatMessage(messages.modifieduserdate, { + date: ( + + ), + user: ( + + + + + {requestData.modifiedBy.displayName} + + + + ), + })} +
)}
- - - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN || - requestData.status === MediaRequestStatus.DECLINED ? ( - - {requestData.status === MediaRequestStatus.DECLINED - ? intl.formatMessage(globalMessages.declined) - : intl.formatMessage(globalMessages.failed)} - - ) : ( - 0 - } - is4k={requestData.is4k} - /> - )} - - -
- - - -
-
- -
- {requestData.modifiedBy ? ( - -
- + {requestData.media[requestData.is4k ? 'status4k' : 'status'] === + MediaStatus.UNKNOWN && + requestData.status !== MediaRequestStatus.DECLINED && + hasPermission(Permission.MANAGE_REQUESTS) && ( + + )} + {requestData.status !== MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deleterequest)} + + )} + {requestData.status === MediaRequestStatus.PENDING && + hasPermission(Permission.MANAGE_REQUESTS) && ( +
+ + + + +
- - ) : ( - N/A - )} -
- - - {requestData.media[requestData.is4k ? 'status4k' : 'status'] === - MediaStatus.UNKNOWN && - requestData.status !== MediaRequestStatus.DECLINED && - hasPermission(Permission.MANAGE_REQUESTS) && ( - - )} - {requestData.status !== MediaRequestStatus.PENDING && - hasPermission(Permission.MANAGE_REQUESTS) && ( - - )} - {requestData.status === MediaRequestStatus.PENDING && - hasPermission(Permission.MANAGE_REQUESTS) && ( - <> - - - - - - - + )} + {requestData.status === MediaRequestStatus.PENDING && + (hasPermission(Permission.MANAGE_REQUESTS) || + (requestData.requestedBy.id === user?.id && + (requestData.type === 'tv' || + hasPermission(Permission.REQUEST_ADVANCED)))) && ( + - - )} - - + )} + {requestData.status === MediaRequestStatus.PENDING && + !hasPermission(Permission.MANAGE_REQUESTS) && + requestData.requestedBy.id === user?.id && ( + deleteRequest()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.cancelRequest)} + + )} +
+
+ ); }; diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index 0be3bb008..a21cbfdfc 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -1,51 +1,98 @@ -import React, { useState } from 'react'; +import { + ChevronLeftIcon, + ChevronRightIcon, + FilterIcon, + SortDescendingIcon, +} from '@heroicons/react/solid'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import RequestItem from './RequestItem'; -import Header from '../Common/Header'; -import Table from '../Common/Table'; +import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams'; +import { useUser } from '../../hooks/useUser'; +import globalMessages from '../../i18n/globalMessages'; import Button from '../Common/Button'; -import { defineMessages, useIntl } from 'react-intl'; +import Header from '../Common/Header'; +import LoadingSpinner from '../Common/LoadingSpinner'; import PageTitle from '../Common/PageTitle'; +import RequestItem from './RequestItem'; const messages = defineMessages({ requests: 'Requests', - mediaInfo: 'Media Info', - status: 'Status', - requestedAt: 'Requested At', - modifiedBy: 'Last Modified By', - showingresults: - 'Showing {from} to {to} of {total} results', - resultsperpage: 'Display {pageSize} results per page', - next: 'Next', - previous: 'Previous', - filterAll: 'All', - filterPending: 'Pending', - filterApproved: 'Approved', - filterAvailable: 'Available', - filterProcessing: 'Processing', - noresults: 'No results.', showallrequests: 'Show All Requests', - sortAdded: 'Request Date', + sortAdded: 'Most Recent', sortModified: 'Last Modified', }); -type Filter = 'all' | 'pending' | 'approved' | 'processing' | 'available'; +enum Filter { + ALL = 'all', + PENDING = 'pending', + APPROVED = 'approved', + PROCESSING = 'processing', + AVAILABLE = 'available', + UNAVAILABLE = 'unavailable', +} + type Sort = 'added' | 'modified'; const RequestList: React.FC = () => { + const router = useRouter(); const intl = useIntl(); - const [pageIndex, setPageIndex] = useState(0); - const [currentFilter, setCurrentFilter] = useState('pending'); + const { user } = useUser({ + id: Number(router.query.userId), + }); + const [currentFilter, setCurrentFilter] = useState(Filter.PENDING); const [currentSort, setCurrentSort] = useState('added'); const [currentPageSize, setCurrentPageSize] = useState(10); - const { data, error, revalidate } = useSWR( + const page = router.query.page ? Number(router.query.page) : 1; + const pageIndex = page - 1; + const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + + const { + data, + error, + mutate: revalidate, + } = useSWR( `/api/v1/request?take=${currentPageSize}&skip=${ pageIndex * currentPageSize - }&filter=${currentFilter}&sort=${currentSort}` + }&filter=${currentFilter}&sort=${currentSort}${ + router.query.userId ? `&requestedBy=${router.query.userId}` : '' + }` ); + + // Restore last set filter values on component mount + useEffect(() => { + const filterString = window.localStorage.getItem('rl-filter-settings'); + + if (filterString) { + const filterSettings = JSON.parse(filterString); + + setCurrentFilter(filterSettings.currentFilter); + setCurrentSort(filterSettings.currentSort); + setCurrentPageSize(filterSettings.currentPageSize); + } + + // If filter value is provided in query, use that instead + if (Object.values(Filter).includes(router.query.filter as Filter)) { + setCurrentFilter(router.query.filter as Filter); + } + }, [router.query.filter]); + + // Set filter values to local storage any time they are changed + useEffect(() => { + window.localStorage.setItem( + 'rl-filter-settings', + JSON.stringify({ + currentFilter, + currentSort, + currentPageSize, + }) + ); + }, [currentFilter, currentSort, currentPageSize]); + if (!data && !error) { return ; } @@ -59,73 +106,81 @@ const RequestList: React.FC = () => { return ( <> - -
-
{intl.formatMessage(messages.requests)}
-
-
- - - - + +
+
+ {user?.displayName} + + ) : ( + '' + ) + } + > + {intl.formatMessage(messages.requests)} +
+
+
+ +
-
- - - - +
+ + { - setPageIndex(0); - setCurrentPageSize(Number(e.target.value)); - }} - value={currentPageSize} - className="inline short" - > - - - - - - - ), - })} - -
-
- +
+ )} +
+ )} +
+ +
); }; diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index ccfa4f1f9..648f9aff0 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -23,12 +23,14 @@ const messages = defineMessages({ requesttitle: 'Request {title}', request4ktitle: 'Request {title} in 4K', edit: 'Edit Request', + approve: 'Approve Request', cancel: 'Cancel Request', pendingrequest: 'Pending Request for {title}', pending4krequest: 'Pending 4K Request for {title}', requestfrom: "{username}'s request is pending approval.", errorediting: 'Something went wrong while editing the request.', requestedited: 'Request for {title} edited successfully!', + requestApproved: 'Request for {title} approved!', requesterror: 'Something went wrong while submitting the request.', pendingapproval: 'Your request is pending approval.', }); @@ -60,7 +62,10 @@ const MovieRequestModal: React.FC = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const { data: quota } = useSWR( - 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(() => { @@ -156,7 +161,7 @@ const MovieRequestModal: React.FC = ({ } }; - const updateRequest = async () => { + const updateRequest = async (alsoApproveRequest = false) => { setIsUpdating(true); try { @@ -169,14 +174,23 @@ const MovieRequestModal: React.FC = ({ tags: requestOverrides?.tags, }); + if (alsoApproveRequest) { + await axios.post(`/api/v1/request/${editRequest?.id}/approve`); + } + addToast( - {intl.formatMessage(messages.requestedited, { - title: data?.title, - strong: function strong(msg) { - return {msg}; - }, - })} + {intl.formatMessage( + alsoApproveRequest + ? messages.requestApproved + : messages.requestedited, + { + title: data?.title, + strong: function strong(msg) { + return {msg}; + }, + } + )} , { appearance: 'success', @@ -199,12 +213,6 @@ const MovieRequestModal: React.FC = ({ if (editRequest) { const isOwner = editRequest.requestedBy.id === user?.id; - const showEditButton = hasPermission( - [Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED], - { - type: 'or', - } - ); return ( = ({ is4k ? messages.pending4krequest : messages.pendingrequest, { title: data?.title } )} - onOk={() => (showEditButton ? updateRequest() : cancelRequest())} + onOk={() => + hasPermission(Permission.MANAGE_REQUESTS) + ? updateRequest(true) + : hasPermission(Permission.REQUEST_ADVANCED) + ? updateRequest() + : cancelRequest() + } okDisabled={isUpdating} okText={ - showEditButton + hasPermission(Permission.MANAGE_REQUESTS) + ? intl.formatMessage(messages.approve) + : hasPermission(Permission.REQUEST_ADVANCED) ? intl.formatMessage(messages.edit) : intl.formatMessage(messages.cancel) } - okButtonType={showEditButton ? 'primary' : 'danger'} + okButtonType={ + hasPermission(Permission.MANAGE_REQUESTS) + ? 'success' + : hasPermission(Permission.REQUEST_ADVANCED) + ? 'primary' + : 'danger' + } onSecondary={ - isOwner && showEditButton ? () => cancelRequest() : undefined + isOwner && + hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) + ? () => cancelRequest() + : undefined } secondaryDisabled={isUpdating} secondaryText={ - isOwner && showEditButton + isOwner && + hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) ? intl.formatMessage(messages.cancel) : undefined } @@ -244,22 +276,20 @@ const MovieRequestModal: React.FC = ({ })} {(hasPermission(Permission.REQUEST_ADVANCED) || hasPermission(Permission.MANAGE_REQUESTS)) && ( -
- { - setRequestOverrides(overrides); - }} - /> -
+ { + setRequestOverrides(overrides); + }} + /> )}
); diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 1d6a9e640..0a6da80f0 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -30,13 +30,15 @@ const messages = defineMessages({ requesttitle: 'Request {title}', request4ktitle: 'Request {title} in 4K', edit: 'Edit Request', + approve: 'Approve Request', cancel: 'Cancel Request', pendingrequest: 'Pending Request for {title}', pending4krequest: 'Pending 4K Request for {title}', requestfrom: "{username}'s request is pending approval.", requestseasons: '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', selectseason: 'Select Season(s)', season: 'Season', @@ -45,6 +47,7 @@ const messages = defineMessages({ extras: 'Extras', errorediting: 'Something went wrong while editing the request.', requestedited: 'Request for {title} edited successfully!', + requestApproved: 'Request for {title} approved!', requestcancelled: 'Request for {title} canceled.', autoapproval: 'Automatic Approval', requesterror: 'Something went wrong while submitting the request.', @@ -88,7 +91,10 @@ const TvRequestModal: React.FC = ({ }); const [tvdbId, setTvdbId] = useState(undefined); const { data: quota } = useSWR( - 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 = @@ -96,7 +102,7 @@ const TvRequestModal: React.FC = ({ selectedSeasons.length + (editRequest?.seasons ?? []).length; - const updateRequest = async () => { + const updateRequest = async (alsoApproveRequest = false) => { if (!editRequest) { return; } @@ -117,6 +123,10 @@ const TvRequestModal: React.FC = ({ tags: requestOverrides?.tags, seasons: selectedSeasons, }); + + if (alsoApproveRequest) { + await axios.post(`/api/v1/request/${editRequest.id}/approve`); + } } else { await axios.delete(`/api/v1/request/${editRequest.id}`); } @@ -124,12 +134,17 @@ const TvRequestModal: React.FC = ({ addToast( {selectedSeasons.length > 0 - ? intl.formatMessage(messages.requestedited, { - title: data?.name, - strong: function strong(msg) { - return {msg}; - }, - }) + ? intl.formatMessage( + alsoApproveRequest + ? messages.requestApproved + : messages.requestedited, + { + title: data?.name, + strong: function strong(msg) { + return {msg}; + }, + } + ) : intl.formatMessage(messages.requestcancelled, { title: data?.name, strong: function strong(msg) { @@ -368,7 +383,13 @@ const TvRequestModal: React.FC = ({ loading={!data && !error} backgroundClickable onCancel={tvdbId ? () => setSearchModal({ show: true }) : onCancel} - onOk={() => (editRequest ? updateRequest() : sendRequest())} + onOk={() => + editRequest + ? hasPermission(Permission.MANAGE_REQUESTS) + ? updateRequest(true) + : updateRequest() + : sendRequest() + } title={intl.formatMessage( editRequest ? is4k @@ -383,16 +404,23 @@ const TvRequestModal: React.FC = ({ editRequest ? selectedSeasons.length === 0 ? intl.formatMessage(messages.cancel) + : hasPermission(Permission.MANAGE_REQUESTS) + ? intl.formatMessage(messages.approve) : intl.formatMessage(messages.edit) : getAllRequestedSeasons().length >= getAllSeasons().length ? intl.formatMessage(messages.alreadyrequested) : !settings.currentSettings.partialRequestsEnabled - ? intl.formatMessage(messages.requestall) + ? intl.formatMessage( + is4k ? globalMessages.request4k : globalMessages.request + ) : selectedSeasons.length === 0 ? intl.formatMessage(messages.selectseason) - : intl.formatMessage(messages.requestseasons, { - seasonCount: selectedSeasons.length, - }) + : intl.formatMessage( + is4k ? messages.requestseasons4k : messages.requestseasons, + { + seasonCount: selectedSeasons.length, + } + ) } okDisabled={ editRequest @@ -406,11 +434,14 @@ const TvRequestModal: React.FC = ({ selectedSeasons.length === 0) } okButtonType={ - editRequest && - settings.currentSettings.partialRequestsEnabled && - selectedSeasons.length === 0 - ? 'danger' - : `primary` + editRequest + ? settings.currentSettings.partialRequestsEnabled && + selectedSeasons.length === 0 + ? 'danger' + : hasPermission(Permission.MANAGE_REQUESTS) + ? 'success' + : 'primary' + : 'primary' } cancelText={ editRequest @@ -440,7 +471,7 @@ const TvRequestModal: React.FC = ({ !( quota?.tv.limit && !settings.currentSettings.partialRequestsEnabled && - unrequestedSeasons.length > (quota?.tv.limit ?? 0) + unrequestedSeasons.length > (quota?.tv.remaining ?? 0) ) && getAllRequestedSeasons().length < getAllSeasons().length && !editRequest && ( @@ -457,7 +488,7 @@ const TvRequestModal: React.FC = ({ quota={quota?.tv} remaining={ !settings.currentSettings.partialRequestsEnabled && - unrequestedSeasons.length > (quota?.tv.limit ?? 0) + unrequestedSeasons.length > (quota?.tv.remaining ?? 0) ? 0 : currentlyRemaining } @@ -468,7 +499,7 @@ const TvRequestModal: React.FC = ({ } overLimit={ !settings.currentSettings.partialRequestsEnabled && - unrequestedSeasons.length > (quota?.tv.limit ?? 0) + unrequestedSeasons.length > (quota?.tv.remaining ?? 0) ? unrequestedSeasons.length : undefined } @@ -482,7 +513,7 @@ const TvRequestModal: React.FC = ({ = ({ toggleAllSeasons(); } }} - className={`relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${ + className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ quota?.tv.remaining && quota.tv.limit && quota.tv.remaining < unrequestedSeasons.length @@ -509,28 +540,28 @@ const TvRequestModal: React.FC = ({ aria-hidden="true" className={`${ isAllSeasons() ? 'bg-indigo-500' : 'bg-gray-800' - } absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`} + } absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`} > - + {intl.formatMessage(messages.season)} - + {intl.formatMessage(messages.numberofepisodes)} - + {intl.formatMessage(globalMessages.status)} - + {data?.seasons .filter((season) => season.seasonNumber !== 0) .map((season) => { @@ -546,7 +577,7 @@ const TvRequestModal: React.FC = ({ return ( = ({ toggleSeason(season.seasonNumber); } }} - className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${ + className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ mediaSeason || (quota?.tv.limit && currentlyRemaining <= 0 && @@ -590,7 +621,7 @@ const TvRequestModal: React.FC = ({ isSelectedSeason(season.seasonNumber) ? 'bg-indigo-500' : 'bg-gray-800' - } absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`} + } absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`} > - + {season.seasonNumber === 0 ? intl.formatMessage(messages.extras) : intl.formatMessage(messages.seasonnumber, { number: season.seasonNumber, })} - + {season.episodeCount} - + {!seasonRequest && !mediaSeason && ( {intl.formatMessage( @@ -667,28 +698,26 @@ const TvRequestModal: React.FC = ({
{(hasPermission(Permission.REQUEST_ADVANCED) || hasPermission(Permission.MANAGE_REQUESTS)) && ( -
- keyword.id === ANIME_KEYWORD_ID - )} - onChange={(overrides) => setRequestOverrides(overrides)} - requestUser={editRequest?.requestedBy} - defaultOverrides={ - editRequest - ? { - folder: editRequest.rootFolder, - profile: editRequest.profileId, - server: editRequest.serverId, - language: editRequest.languageProfileId, - tags: editRequest.tags, - } - : undefined - } - /> -
+ keyword.id === ANIME_KEYWORD_ID + )} + onChange={(overrides) => setRequestOverrides(overrides)} + requestUser={editRequest?.requestedBy} + defaultOverrides={ + editRequest + ? { + folder: editRequest.rootFolder, + profile: editRequest.profileId, + server: editRequest.serverId, + language: editRequest.languageProfileId, + tags: editRequest.tags, + } + : undefined + } + /> )} ); diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index 7ba09bdef..578eebd41 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import MovieRequestModal from './MovieRequestModal'; import type { MediaStatus } from '../../../server/constants/media'; -import TvRequestModal from './TvRequestModal'; -import Transition from '../Transition'; import { MediaRequest } from '../../../server/entity/MediaRequest'; +import Transition from '../Transition'; +import MovieRequestModal from './MovieRequestModal'; +import TvRequestModal from './TvRequestModal'; interface RequestModalProps { show: boolean; @@ -26,29 +26,6 @@ const RequestModal: React.FC = ({ onUpdating, onCancel, }) => { - if (type === 'tv') { - return ( - - - - ); - } - return ( = ({ leaveTo="opacity-0" show={show} > - + {type === 'movie' ? ( + + ) : ( + + )} ); }; diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index fe101a9fd..fd78cd7b8 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -13,8 +13,11 @@ interface StatusBadgeProps { status?: MediaStatus; is4k?: boolean; inProgress?: boolean; + plexUrl?: string; + tmdbId?: number; mediaUrl?: string; mediaUrl4k?: string; + mediaType?: 'movie' | 'tv'; } const StatusBadge: React.FC = ({