fix: fixed request card not displaying the requested season and episodes

When requested, the request card shows as {seasonCount, plural, one {Season}} and does not display
which season or episode was requested because it was still using the alpha request cards. This fixed
that issue
This commit is contained in:
Fallenbagel
2022-04-13 17:24:47 +05:00
parent 73672e29f8
commit b03b9b1dbb
7 changed files with 1074 additions and 685 deletions

View File

@@ -1,24 +1,38 @@
import React, { useContext, useEffect } from 'react'; import {
import { useInView } from 'react-intersection-observer'; CheckIcon,
import type { MediaRequest } from '../../../server/entity/MediaRequest'; PencilIcon,
import type { TvDetails } from '../../../server/models/Tv'; RefreshIcon,
import type { MovieDetails } from '../../../server/models/Movie'; TrashIcon,
import useSWR from 'swr'; XIcon,
import { LanguageContext } from '../../context/LanguageContext'; } from '@heroicons/react/solid';
import { MediaRequestStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge';
import { useUser, Permission } from '../../hooks/useUser';
import axios from 'axios'; import axios from 'axios';
import Button from '../Common/Button';
import { withProperties } from '../../utils/typeHelpers';
import Link from 'next/link'; import Link from 'next/link';
import React, { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, useIntl } from 'react-intl'; 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 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'; import StatusBadge from '../StatusBadge';
const messages = defineMessages({ const messages = defineMessages({
seasons: 'Seasons', seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
all: 'All', 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 => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -27,7 +41,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
const RequestCardPlaceholder: React.FC = () => { const RequestCardPlaceholder: React.FC = () => {
return ( return (
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse"> <div className="relative w-72 animate-pulse rounded-xl bg-gray-700 p-4 sm:w-96">
<div className="w-20 sm:w-28"> <div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }} /> <div className="w-full" style={{ paddingBottom: '150%' }} />
</div> </div>
@@ -35,6 +49,45 @@ const RequestCardPlaceholder: React.FC = () => {
); );
}; };
interface RequestCardErrorProps {
mediaId?: number;
}
const RequestCardError: React.FC<RequestCardErrorProps> = ({ 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 (
<div className="relative w-72 rounded-xl bg-gray-800 p-4 ring-1 ring-red-500 sm:w-96">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center px-10">
<div className="w-full whitespace-normal text-center text-xs text-gray-300 sm:text-sm">
{intl.formatMessage(messages.mediaerror)}
</div>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<Button
buttonType="danger"
buttonSize="sm"
className="mt-4"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
)}
</div>
</div>
</div>
</div>
);
};
interface RequestCardProps { interface RequestCardProps {
request: MediaRequest; request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
@@ -45,19 +98,21 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
triggerOnce: true, triggerOnce: true,
}); });
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { user, hasPermission } = useUser();
const { locale } = useContext(LanguageContext); const { addToast } = useToasts();
const [isRetrying, setRetrying] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? `${url}` : null
); );
const { const {
data: requestData, data: requestData,
error: requestError, error: requestError,
revalidate, mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, { } = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
initialData: request, initialData: request,
}); });
@@ -70,6 +125,30 @@ const RequestCard: React.FC<RequestCardProps> = ({ 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(() => { useEffect(() => {
if (title && onTitleData) { if (title && onTitleData) {
onTitleData(request.id, title); onTitleData(request.id, title);
@@ -85,157 +164,251 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
} }
if (!requestData && !requestError) { if (!requestData && !requestError) {
return <RequestCardPlaceholder />; return <RequestCardError />;
} }
if (!title || !requestData) { if (!title || !requestData) {
return <RequestCardPlaceholder />; return <RequestCardError mediaId={requestData?.media.id} />;
} }
return ( return (
<div <>
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96" <RequestModal
style={{ show={showEditModal}
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`, tmdbId={request.media.tmdbId}
}} type={request.type}
> is4k={request.is4k}
<div className="flex flex-col flex-1 min-w-0 pr-4"> editRequest={request}
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline"> onCancel={() => setShowEditModal(false)}
<Link onComplete={() => {
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'} revalidate();
as={ setShowEditModal(false);
request.type === 'movie' }}
? `/movie/${request.media.tmdbId}` />
: `/tv/${request.media.tmdbId}` <div className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96">
} {title.backdropPath && (
> <div className="absolute inset-0 z-0">
{isMovie(title) ? title.title : title.name} <CachedImage
</Link>
</h2>
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt="" alt=""
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5" src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/> />
<span className="text-xs truncate sm:text-sm group-hover:underline"> <div
{requestData.requestedBy.displayName} className="absolute inset-0"
</span> style={{
</a> backgroundImage:
</Link> 'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
{requestData.media.status && ( }}
<div className="mt-1 sm:mt-2">
<StatusBadge
status={
requestData.is4k
? requestData.media.status4k
: requestData.media.status
}
is4k={requestData.is4k}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
/> />
</div> </div>
)} )}
{request.seasons.length > 0 && ( <div className="relative z-10 flex min-w-0 flex-1 flex-col pr-4">
<div className="items-center hidden mt-2 text-sm sm:flex"> <div className="hidden text-xs font-medium text-white sm:flex">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span> {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
{!isMovie(title) && 0,
title.seasons.filter((season) => season.seasonNumber !== 0) 4
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(messages.all)}</Badge>
</span>
) : (
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)} )}
</div> </div>
)} <Link
{requestData.status === MediaRequestStatus.PENDING && href={
hasPermission(Permission.MANAGE_REQUESTS) && ( request.type === 'movie'
<div className="flex items-end flex-1"> ? `/movie/${requestData.media.tmdbId}`
<span className="mr-2"> : `/tv/${requestData.media.tmdbId}`
}
>
<a className="overflow-hidden overflow-ellipsis whitespace-nowrap text-base font-bold text-white hover:underline sm:text-lg">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) && (
<div className="card-field">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="group flex items-center">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate font-semibold group-hover:text-white group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
)}
{!isMovie(title) && request.seasons.length > 0 && (
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
{title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span>
) : (
<div className="hide-scrollbar overflow-x-scroll">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
<div className="mt-2 flex items-center text-sm sm:mt-1">
<span className="mr-2 hidden font-bold sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.declined)}
</Badge>
) : requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ? (
<Badge
badgeType="danger"
//href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
>
{intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/>
)}
</div>
<div className="flex flex-1 items-end space-x-2">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <Button
buttonType="success" buttonType="primary"
buttonSize="sm" buttonSize="sm"
onClick={() => modifyRequest('approve')} disabled={isRetrying}
onClick={() => retryRequest()}
> >
<svg <RefreshIcon
className="w-4 h-4 mr-0 sm:mr-1" className={isRetrying ? 'animate-spin' : ''}
fill="currentColor" style={{ marginRight: '0', animationDirection: 'reverse' }}
viewBox="0 0 20 20" />
xmlns="http://www.w3.org/2000/svg" <span className="ml-1.5 hidden sm:block">
> {intl.formatMessage(globalMessages.retry)}
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
</span> </span>
</Button> </Button>
</span> )}
<span> {requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<CheckIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<XIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id &&
(requestData.type === 'tv' ||
hasPermission(Permission.REQUEST_ADVANCED)) && (
<Button
buttonType="primary"
buttonSize="sm"
onClick={() => setShowEditModal(true)}
className={`${
hasPermission(Permission.MANAGE_REQUESTS) ? 'sm:hidden' : ''
}`}
>
<PencilIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.edit)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<Button <Button
buttonType="danger" buttonType="danger"
buttonSize="sm" buttonSize="sm"
onClick={() => modifyRequest('decline')} onClick={() => deleteRequest()}
> >
<svg <XIcon style={{ marginRight: '0' }} />
className="w-4 h-4 mr-0 sm:mr-1" <span className="ml-1.5 hidden sm:block">
fill="currentColor" {intl.formatMessage(globalMessages.cancel)}
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
</span> </span>
</Button> </Button>
</span> )}
</div> </div>
)} </div>
</div>
<div className="flex-shrink-0 w-20 sm:w-28">
<Link <Link
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'} href={
as={
request.type === 'movie' request.type === 'movie'
? `/movie/${request.media.tmdbId}` ? `/movie/${requestData.media.tmdbId}`
: `/tv/${request.media.tmdbId}` : `/tv/${requestData.media.tmdbId}`
} }
> >
<img <a className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28">
src={ <CachedImage
title.posterPath src={
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` title.posterPath
: '/images/overseerr_poster_not_found.png' ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
} : '/images/overseerr_poster_not_found.png'
alt="" }
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md" alt=""
/> layout="responsive"
width={600}
height={900}
/>
</a>
</Link> </Link>
</div> </div>
</div> </>
); );
}; };

View File

@@ -1,13 +1,15 @@
import {
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
import React, { useContext, useState } from 'react'; import React, { useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
defineMessages,
FormattedDate,
FormattedRelativeTime,
useIntl,
} from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
@@ -17,25 +19,70 @@ import {
import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import type { MovieDetails } from '../../../../server/models/Movie'; import type { MovieDetails } from '../../../../server/models/Movie';
import type { TvDetails } from '../../../../server/models/Tv'; import type { TvDetails } from '../../../../server/models/Tv';
import { LanguageContext } from '../../../context/LanguageContext';
import { Permission, useUser } from '../../../hooks/useUser'; import { Permission, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import Badge from '../../Common/Badge'; import Badge from '../../Common/Badge';
import Button from '../../Common/Button'; 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 RequestModal from '../../RequestModal';
import StatusBadge from '../../StatusBadge'; import StatusBadge from '../../StatusBadge';
const messages = defineMessages({ const messages = defineMessages({
seasons: 'Seasons', seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
notavailable: 'N/A',
failedretry: 'Something went wrong while retrying the request.', 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 => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined; return (movie as MovieDetails).title !== undefined;
}; };
interface RequestItemErroProps {
mediaId?: number;
revalidateList: () => void;
}
const RequestItemError: React.FC<RequestItemErroProps> = ({
mediaId,
revalidateList,
}) => {
const intl = useIntl();
const { hasPermission } = useUser();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
revalidateList();
};
return (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-xl bg-gray-800 px-10 ring-1 ring-red-500 lg:flex-row xl:h-28">
<span className="text-center text-sm text-gray-300 lg:text-left">
{intl.formatMessage(messages.mediaerror)}
</span>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<div className="mt-4 lg:ml-4 lg:mt-0">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
</div>
)}
</div>
);
};
interface RequestItemProps { interface RequestItemProps {
request: MediaRequest; request: MediaRequest;
revalidateList: () => void; revalidateList: () => void;
@@ -50,23 +97,21 @@ const RequestItem: React.FC<RequestItemProps> = ({
}); });
const { addToast } = useToasts(); const { addToast } = useToasts();
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { user, hasPermission } = useUser();
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const { locale } = useContext(LanguageContext);
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? url : null
);
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
initialData: request,
}
); );
const {
data: requestData,
revalidate,
mutate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
initialData: request,
});
const [isRetrying, setRetrying] = useState(false); const [isRetrying, setRetrying] = useState(false);
@@ -89,7 +134,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
try { try {
const result = await axios.post(`/api/v1/request/${request.id}/retry`); const result = await axios.post(`/api/v1/request/${request.id}/retry`);
mutate(result.data); revalidate(result.data);
} catch (e) { } catch (e) {
addToast(intl.formatMessage(messages.failedretry), { addToast(intl.formatMessage(messages.failedretry), {
autoDismiss: true, autoDismiss: true,
@@ -102,22 +147,24 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title && !error) { if (!title && !error) {
return ( return (
<tr className="w-full h-24 animate-pulse" ref={ref}> <div
<td colSpan={6}></td> className="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28"
</tr> ref={ref}
/>
); );
} }
if (!title || !requestData) { if (!title || !requestData) {
return ( return (
<tr className="w-full h-24 animate-pulse"> <RequestItemError
<td colSpan={6}></td> mediaId={requestData?.media.id}
</tr> revalidateList={revalidateList}
/>
); );
} }
return ( return (
<tr className="relative w-full h-24 p-2"> <>
<RequestModal <RequestModal
show={showEditModal} show={showEditModal}
tmdbId={request.media.tmdbId} tmdbId={request.media.tmdbId}
@@ -130,28 +177,26 @@ const RequestItem: React.FC<RequestItemProps> = ({
setShowEditModal(false); setShowEditModal(false);
}} }}
/> />
<Table.TD> <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
<div className="flex items-center"> {title.backdropPath && (
<Link <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
href={ <CachedImage
request.type === 'movie' src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
? `/movie/${request.media.tmdbId}` alt=""
: `/tv/${request.media.tmdbId}` layout="fill"
} objectFit="cover"
> />
<a className="flex-shrink-0 hidden mr-4 sm:block"> <div
<img className="absolute inset-0"
src={ style={{
title.posterPath backgroundImage:
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` 'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
: '/images/overseerr_poster_not_found.png' }}
} />
alt="" </div>
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md" )}
/> <div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
</a> <div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
</Link>
<div className="flex-shrink overflow-hidden">
<Link <Link
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
@@ -159,219 +204,295 @@ const RequestItem: React.FC<RequestItemProps> = ({
: `/tv/${requestData.media.tmdbId}` : `/tv/${requestData.media.tmdbId}`
} }
> >
<a className="min-w-0 mr-2 text-xl text-white truncate hover:underline"> <a className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105">
{isMovie(title) ? title.title : title.name} <CachedImage
</a> src={
</Link> title.posterPath
<Link href={`/users/${requestData.requestedBy.id}`}> ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
<a className="flex items-center mt-1"> : '/images/overseerr_poster_not_found.png'
<img }
src={requestData.requestedBy.avatar}
alt="" alt=""
className="w-5 mr-2 rounded-full" layout="responsive"
width={600}
height={900}
objectFit="cover"
/> />
<span className="text-sm hover:underline">
{requestData.requestedBy.displayName}
</span>
</a> </a>
</Link> </Link>
{requestData.seasons.length > 0 && ( <div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="items-center hidden mt-2 text-sm sm:flex"> <div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
<span className="mr-2"> {(isMovie(title)
{intl.formatMessage(messages.seasons)} ? title.releaseDate
</span> : title.firstAirDate
{requestData.seasons.map((season) => ( )?.slice(0, 4)}
<span key={`season-${season.id}`} className="mr-2"> </div>
<Badge>{season.seasonNumber}</Badge> <Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{!isMovie(title) && request.seasons.length > 0 && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter(
(season) => season.seasonNumber !== 0
).length === request.seasons.length
? 0
: request.seasons.length,
})}
</span> </span>
))} {title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length ? (
<span className="mr-2 uppercase">
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span>
) : (
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div>
)}
</div>
)}
</div>
</div>
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.declined)}
</Badge>
) : requestData.media[
requestData.is4k ? 'status4k' : 'status'
] === MediaStatus.UNKNOWN ? (
<Badge
badgeType="danger"
//href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
>
{intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge
status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/>
)}
</div>
<div className="card-field">
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) ? (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requested)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="group flex items-center truncate">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requesteddate)}
</span>
<span className="flex truncate text-sm text-gray-300">
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.createdAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</>
)}
</div>
{requestData.modifiedBy && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.modified)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.modifieduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(requestData.updatedAt).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.modifiedBy.id}`}>
<a className="group flex items-center truncate">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{requestData.modifiedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</div> </div>
)} )}
</div> </div>
</div> </div>
</Table.TD> <div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<Table.TD> {requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
{requestData.media[requestData.is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN &&
MediaStatus.UNKNOWN || requestData.status !== MediaRequestStatus.DECLINED &&
requestData.status === MediaRequestStatus.DECLINED ? ( hasPermission(Permission.MANAGE_REQUESTS) && (
<Badge badgeType="danger"> <Button
{requestData.status === MediaRequestStatus.DECLINED className="w-full"
? intl.formatMessage(globalMessages.declined) buttonType="primary"
: intl.formatMessage(globalMessages.failed)} disabled={isRetrying}
</Badge> onClick={() => retryRequest()}
) : ( >
<StatusBadge <RefreshIcon
status={requestData.media[requestData.is4k ? 'status4k' : 'status']} className={isRetrying ? 'animate-spin' : ''}
inProgress={ style={{ animationDirection: 'reverse' }}
(
requestData.media[
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
/>
)}
</Table.TD>
<Table.TD>
<div className="flex flex-col">
<span className="text-sm text-gray-300">
<FormattedDate value={requestData.createdAt} />
</span>
</div>
</Table.TD>
<Table.TD>
<div className="flex flex-col">
{requestData.modifiedBy ? (
<span className="text-sm text-gray-300">
<div className="flex items-center">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="w-5 mr-2 rounded-full"
/> />
<span className="text-sm"> <span>
{requestData.modifiedBy.displayName} ( {intl.formatMessage(
<FormattedRelativeTime isRetrying ? globalMessages.retrying : globalMessages.retry
value={Math.floor( )}
(new Date(requestData.updatedAt).getTime() - Date.now()) / </span>
1000 </Button>
)} )}
updateIntervalInSeconds={1} {requestData.status !== MediaRequestStatus.PENDING &&
/> hasPermission(Permission.MANAGE_REQUESTS) && (
) <ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex w-full flex-row space-x-2">
<span className="w-full">
<Button
className="w-full"
buttonType="success"
onClick={() => modifyRequest('approve')}
>
<CheckIcon />
<span>{intl.formatMessage(globalMessages.approve)}</span>
</Button>
</span>
<span className="w-full">
<Button
className="w-full"
buttonType="danger"
onClick={() => modifyRequest('decline')}
>
<XIcon />
<span>{intl.formatMessage(globalMessages.decline)}</span>
</Button>
</span> </span>
</div> </div>
</span> )}
) : ( {requestData.status === MediaRequestStatus.PENDING &&
<span className="text-sm text-gray-300">N/A</span> (hasPermission(Permission.MANAGE_REQUESTS) ||
)} (requestData.requestedBy.id === user?.id &&
</div> (requestData.type === 'tv' ||
</Table.TD> hasPermission(Permission.REQUEST_ADVANCED)))) && (
<Table.TD alignText="right"> <span className="w-full">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
className="mr-2"
buttonType="primary"
buttonSize="sm"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18px"
height="18px"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.delete)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<span className="mr-2">
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
</span>
<span className="mr-2">
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
</span>
<span>
<Button <Button
className="w-full"
buttonType="primary" buttonType="primary"
buttonSize="sm"
onClick={() => setShowEditModal(true)} onClick={() => setShowEditModal(true)}
> >
<svg <PencilIcon />
className="w-4 h-4 mr-0 sm:mr-1" <span>{intl.formatMessage(messages.editrequest)}</span>
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.edit)}
</span>
</Button> </Button>
</span> </span>
</> )}
)} {requestData.status === MediaRequestStatus.PENDING &&
</Table.TD> !hasPermission(Permission.MANAGE_REQUESTS) &&
</tr> requestData.requestedBy.id === user?.id && (
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<XIcon />
<span>{intl.formatMessage(messages.cancelRequest)}</span>
</ConfirmButton>
)}
</div>
</div>
</>
); );
}; };

View File

@@ -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 useSWR from 'swr';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import LoadingSpinner from '../Common/LoadingSpinner'; import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import RequestItem from './RequestItem'; import { useUser } from '../../hooks/useUser';
import Header from '../Common/Header'; import globalMessages from '../../i18n/globalMessages';
import Table from '../Common/Table';
import Button from '../Common/Button'; 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 PageTitle from '../Common/PageTitle';
import RequestItem from './RequestItem';
const messages = defineMessages({ const messages = defineMessages({
requests: 'Requests', requests: 'Requests',
mediaInfo: 'Media Info',
status: 'Status',
requestedAt: 'Requested At',
modifiedBy: 'Last Modified By',
showingresults:
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> 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', showallrequests: 'Show All Requests',
sortAdded: 'Request Date', sortAdded: 'Most Recent',
sortModified: 'Last Modified', 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'; type Sort = 'added' | 'modified';
const RequestList: React.FC = () => { const RequestList: React.FC = () => {
const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const [pageIndex, setPageIndex] = useState(0); const { user } = useUser({
const [currentFilter, setCurrentFilter] = useState<Filter>('pending'); id: Number(router.query.userId),
});
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added'); const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(10); const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const { data, error, revalidate } = useSWR<RequestResultsResponse>( 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<RequestResultsResponse>(
`/api/v1/request?take=${currentPageSize}&skip=${ `/api/v1/request?take=${currentPageSize}&skip=${
pageIndex * currentPageSize 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) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@@ -59,73 +106,81 @@ const RequestList: React.FC = () => {
return ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.requests)} /> <PageTitle
<div className="flex flex-col justify-between lg:items-end lg:flex-row"> title={[
<Header>{intl.formatMessage(messages.requests)}</Header> intl.formatMessage(messages.requests),
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0"> router.query.userId ? user?.displayName : '',
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0"> ]}
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md"> />
<svg <div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
className="w-6 h-6" <Header
fill="currentColor" subtext={
viewBox="0 0 20 20" router.query.userId ? (
xmlns="http://www.w3.org/2000/svg" <Link href={`/users/${user?.id}`}>
> <a className="hover:underline">{user?.displayName}</a>
<path </Link>
fillRule="evenodd" ) : (
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" ''
clipRule="evenodd" )
/> }
</svg> >
{intl.formatMessage(messages.requests)}
</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-6" />
</span> </span>
<select <select
id="filter" id="filter"
name="filter" name="filter"
onChange={(e) => { onChange={(e) => {
setPageIndex(0);
setCurrentFilter(e.target.value as Filter); setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}} }}
value={currentFilter} value={currentFilter}
className="rounded-r-only" className="rounded-r-only"
> >
<option value="all"> <option value="all">
{intl.formatMessage(messages.filterAll)} {intl.formatMessage(globalMessages.all)}
</option> </option>
<option value="pending"> <option value="pending">
{intl.formatMessage(messages.filterPending)} {intl.formatMessage(globalMessages.pending)}
</option> </option>
<option value="approved"> <option value="approved">
{intl.formatMessage(messages.filterApproved)} {intl.formatMessage(globalMessages.approved)}
</option> </option>
<option value="processing"> <option value="processing">
{intl.formatMessage(messages.filterProcessing)} {intl.formatMessage(globalMessages.processing)}
</option> </option>
<option value="available"> <option value="available">
{intl.formatMessage(messages.filterAvailable)} {intl.formatMessage(globalMessages.available)}
</option>
<option value="unavailable">
{intl.formatMessage(globalMessages.unavailable)}
</option> </option>
</select> </select>
</div> </div>
<div className="flex flex-grow mb-2 sm:mb-0 lg:flex-grow-0"> <div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default sm:text-sm rounded-l-md"> <span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<svg <SortDescendingIcon className="h-6 w-6" />
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3 3a1 1 0 000 2h11a1 1 0 100-2H3zM3 7a1 1 0 000 2h7a1 1 0 100-2H3zM3 11a1 1 0 100 2h4a1 1 0 100-2H3zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
</svg>
</span> </span>
<select <select
id="sort" id="sort"
name="sort" name="sort"
onChange={(e) => { onChange={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort);
}}
onBlur={(e) => {
setPageIndex(0);
setCurrentSort(e.target.value as Sort); setCurrentSort(e.target.value as Sort);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}} }}
value={currentSort} value={currentSort}
className="rounded-r-only" className="rounded-r-only"
@@ -140,114 +195,104 @@ const RequestList: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
<Table> {data.results.map((request) => {
<thead> return (
<tr> <div className="py-2" key={`request-list-${request.id}`}>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH> <RequestItem
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH> request={request}
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH> revalidateList={() => revalidate()}
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH> />
<Table.TH></Table.TH> </div>
</tr> );
</thead> })}
<Table.TBody>
{data.results.map((request) => {
return (
<RequestItem
request={request}
key={`request-list-${request.id}`}
revalidateList={() => revalidate()}
/>
);
})}
{data.results.length === 0 && ( {data.results.length === 0 && (
<tr className="relative h-24 p-2 text-white"> <div className="flex w-full flex-col items-center justify-center py-24 text-white">
<Table.TD colSpan={6} noPadding> <span className="text-2xl text-gray-400">
<div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full"> {intl.formatMessage(globalMessages.noresults)}
<span className="text-base"> </span>
{intl.formatMessage(messages.noresults)} {currentFilter !== Filter.ALL && (
</span> <div className="mt-4">
{currentFilter !== 'all' && ( <Button
<div className="mt-4"> buttonType="primary"
<Button onClick={() => setCurrentFilter(Filter.ALL)}
buttonSize="sm"
buttonType="primary"
onClick={() => setCurrentFilter('all')}
>
{intl.formatMessage(messages.showallrequests)}
</Button>
</div>
)}
</div>
</Table.TD>
</tr>
)}
<tr className="bg-gray-700">
<Table.TD colSpan={6} noPadding>
<nav
className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row lg:w-full"
aria-label="Pagination"
> >
<div className="hidden lg:flex lg:flex-1"> {intl.formatMessage(messages.showallrequests)}
<p className="text-sm"> </Button>
{data.results.length > 0 && </div>
intl.formatMessage(messages.showingresults, { )}
from: pageIndex * currentPageSize + 1, </div>
to: )}
data.results.length < currentPageSize <div className="actions">
? pageIndex * currentPageSize + data.results.length <nav
: (pageIndex + 1) * currentPageSize, className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
total: data.pageInfo.results, aria-label="Pagination"
strong: function strong(msg) { >
return <span className="font-medium">{msg}</span>; <div className="hidden lg:flex lg:flex-1">
}, <p className="text-sm">
})} {data.results.length > 0 &&
</p> intl.formatMessage(globalMessages.showingresults, {
</div> from: pageIndex * currentPageSize + 1,
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center"> to:
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0"> data.results.length < currentPageSize
{intl.formatMessage(messages.resultsperpage, { ? pageIndex * currentPageSize + data.results.length
pageSize: ( : (pageIndex + 1) * currentPageSize,
<select total: data.pageInfo.results,
id="pageSize" strong: function strong(msg) {
name="pageSize" return <span className="font-medium">{msg}</span>;
onChange={(e) => { },
setPageIndex(0); })}
setCurrentPageSize(Number(e.target.value)); </p>
}} </div>
value={currentPageSize} <div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
className="inline short" <span className="-mt-3 items-center truncate text-sm sm:mt-0">
> {intl.formatMessage(globalMessages.resultsperpage, {
<option value="5">5</option> pageSize: (
<option value="10">10</option> <select
<option value="25">25</option> id="pageSize"
<option value="50">50</option> name="pageSize"
<option value="100">100</option> onChange={(e) => {
</select> setCurrentPageSize(Number(e.target.value));
), router
})} .push({
</span> pathname: router.pathname,
</div> query: router.query.userId
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1"> ? { userId: router.query.userId }
<Button : {},
disabled={!hasPrevPage} })
onClick={() => setPageIndex((current) => current - 1)} .then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="short inline"
> >
{intl.formatMessage(messages.previous)} <option value="5">5</option>
</Button> <option value="10">10</option>
<Button <option value="25">25</option>
disabled={!hasNextPage} <option value="50">50</option>
onClick={() => setPageIndex((current) => current + 1)} <option value="100">100</option>
> </select>
{intl.formatMessage(messages.next)} ),
</Button> })}
</div> </span>
</nav> </div>
</Table.TD> <div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
</tr> <Button
</Table.TBody> disabled={!hasPrevPage}
</Table> onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</> </>
); );
}; };

View File

@@ -23,12 +23,14 @@ const messages = defineMessages({
requesttitle: 'Request {title}', requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K', request4ktitle: 'Request {title} in 4K',
edit: 'Edit Request', edit: 'Edit Request',
approve: 'Approve Request',
cancel: 'Cancel Request', cancel: 'Cancel Request',
pendingrequest: 'Pending Request for {title}', pendingrequest: 'Pending Request for {title}',
pending4krequest: 'Pending 4K Request for {title}', pending4krequest: 'Pending 4K Request for {title}',
requestfrom: "{username}'s request is pending approval.", requestfrom: "{username}'s request is pending approval.",
errorediting: 'Something went wrong while editing the request.', errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!', requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requestApproved: 'Request for <strong>{title}</strong> approved!',
requesterror: 'Something went wrong while submitting the request.', requesterror: 'Something went wrong while submitting the request.',
pendingapproval: 'Your request is pending approval.', pendingapproval: 'Your request is pending approval.',
}); });
@@ -60,7 +62,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(() => {
@@ -156,7 +161,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
} }
}; };
const updateRequest = async () => { const updateRequest = async (alsoApproveRequest = false) => {
setIsUpdating(true); setIsUpdating(true);
try { try {
@@ -169,14 +174,23 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
tags: requestOverrides?.tags, tags: requestOverrides?.tags,
}); });
if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
}
addToast( addToast(
<span> <span>
{intl.formatMessage(messages.requestedited, { {intl.formatMessage(
title: data?.title, alsoApproveRequest
strong: function strong(msg) { ? messages.requestApproved
return <strong>{msg}</strong>; : messages.requestedited,
}, {
})} title: data?.title,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
}
)}
</span>, </span>,
{ {
appearance: 'success', appearance: 'success',
@@ -199,12 +213,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
if (editRequest) { if (editRequest) {
const isOwner = editRequest.requestedBy.id === user?.id; const isOwner = editRequest.requestedBy.id === user?.id;
const showEditButton = hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
{
type: 'or',
}
);
return ( return (
<Modal <Modal
@@ -215,20 +223,44 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
is4k ? messages.pending4krequest : messages.pendingrequest, is4k ? messages.pending4krequest : messages.pendingrequest,
{ title: data?.title } { title: data?.title }
)} )}
onOk={() => (showEditButton ? updateRequest() : cancelRequest())} onOk={() =>
hasPermission(Permission.MANAGE_REQUESTS)
? updateRequest(true)
: hasPermission(Permission.REQUEST_ADVANCED)
? updateRequest()
: cancelRequest()
}
okDisabled={isUpdating} okDisabled={isUpdating}
okText={ okText={
showEditButton hasPermission(Permission.MANAGE_REQUESTS)
? intl.formatMessage(messages.approve)
: hasPermission(Permission.REQUEST_ADVANCED)
? intl.formatMessage(messages.edit) ? intl.formatMessage(messages.edit)
: intl.formatMessage(messages.cancel) : intl.formatMessage(messages.cancel)
} }
okButtonType={showEditButton ? 'primary' : 'danger'} okButtonType={
hasPermission(Permission.MANAGE_REQUESTS)
? 'success'
: hasPermission(Permission.REQUEST_ADVANCED)
? 'primary'
: 'danger'
}
onSecondary={ onSecondary={
isOwner && showEditButton ? () => cancelRequest() : undefined isOwner &&
hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? () => cancelRequest()
: undefined
} }
secondaryDisabled={isUpdating} secondaryDisabled={isUpdating}
secondaryText={ secondaryText={
isOwner && showEditButton isOwner &&
hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? intl.formatMessage(messages.cancel) ? intl.formatMessage(messages.cancel)
: undefined : undefined
} }
@@ -244,22 +276,20 @@ 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} requestUser={editRequest.requestedBy}
requestUser={editRequest.requestedBy} defaultOverrides={{
defaultOverrides={{ folder: editRequest.rootFolder,
folder: editRequest.rootFolder, profile: editRequest.profileId,
profile: editRequest.profileId, server: editRequest.serverId,
server: editRequest.serverId, tags: editRequest.tags,
tags: editRequest.tags, }}
}} onChange={(overrides) => {
onChange={(overrides) => { setRequestOverrides(overrides);
setRequestOverrides(overrides); }}
}} />
/>
</div>
)} )}
</Modal> </Modal>
); );

View File

@@ -30,13 +30,15 @@ const messages = defineMessages({
requesttitle: 'Request {title}', requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K', request4ktitle: 'Request {title} in 4K',
edit: 'Edit Request', edit: 'Edit Request',
approve: 'Approve Request',
cancel: 'Cancel Request', cancel: 'Cancel Request',
pendingrequest: 'Pending Request for {title}', pendingrequest: 'Pending Request for {title}',
pending4krequest: 'Pending 4K Request for {title}', pending4krequest: 'Pending 4K Request for {title}',
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',
@@ -45,6 +47,7 @@ const messages = defineMessages({
extras: 'Extras', extras: 'Extras',
errorediting: 'Something went wrong while editing the request.', errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!', requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requestApproved: 'Request for <strong>{title}</strong> approved!',
requestcancelled: 'Request for <strong>{title}</strong> canceled.', requestcancelled: 'Request for <strong>{title}</strong> canceled.',
autoapproval: 'Automatic Approval', autoapproval: 'Automatic Approval',
requesterror: 'Something went wrong while submitting the request.', requesterror: 'Something went wrong while submitting the request.',
@@ -88,7 +91,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 =
@@ -96,7 +102,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
selectedSeasons.length + selectedSeasons.length +
(editRequest?.seasons ?? []).length; (editRequest?.seasons ?? []).length;
const updateRequest = async () => { const updateRequest = async (alsoApproveRequest = false) => {
if (!editRequest) { if (!editRequest) {
return; return;
} }
@@ -117,6 +123,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
tags: requestOverrides?.tags, tags: requestOverrides?.tags,
seasons: selectedSeasons, seasons: selectedSeasons,
}); });
if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest.id}/approve`);
}
} else { } else {
await axios.delete(`/api/v1/request/${editRequest.id}`); await axios.delete(`/api/v1/request/${editRequest.id}`);
} }
@@ -124,12 +134,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
addToast( addToast(
<span> <span>
{selectedSeasons.length > 0 {selectedSeasons.length > 0
? intl.formatMessage(messages.requestedited, { ? intl.formatMessage(
title: data?.name, alsoApproveRequest
strong: function strong(msg) { ? messages.requestApproved
return <strong>{msg}</strong>; : messages.requestedited,
}, {
}) title: data?.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
}
)
: intl.formatMessage(messages.requestcancelled, { : intl.formatMessage(messages.requestcancelled, {
title: data?.name, title: data?.name,
strong: function strong(msg) { strong: function strong(msg) {
@@ -368,7 +383,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
loading={!data && !error} loading={!data && !error}
backgroundClickable backgroundClickable
onCancel={tvdbId ? () => setSearchModal({ show: true }) : onCancel} onCancel={tvdbId ? () => setSearchModal({ show: true }) : onCancel}
onOk={() => (editRequest ? updateRequest() : sendRequest())} onOk={() =>
editRequest
? hasPermission(Permission.MANAGE_REQUESTS)
? updateRequest(true)
: updateRequest()
: sendRequest()
}
title={intl.formatMessage( title={intl.formatMessage(
editRequest editRequest
? is4k ? is4k
@@ -383,16 +404,23 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
editRequest editRequest
? selectedSeasons.length === 0 ? selectedSeasons.length === 0
? intl.formatMessage(messages.cancel) ? intl.formatMessage(messages.cancel)
: hasPermission(Permission.MANAGE_REQUESTS)
? intl.formatMessage(messages.approve)
: intl.formatMessage(messages.edit) : intl.formatMessage(messages.edit)
: 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(
seasonCount: selectedSeasons.length, is4k ? messages.requestseasons4k : messages.requestseasons,
}) {
seasonCount: selectedSeasons.length,
}
)
} }
okDisabled={ okDisabled={
editRequest editRequest
@@ -406,11 +434,14 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
selectedSeasons.length === 0) selectedSeasons.length === 0)
} }
okButtonType={ okButtonType={
editRequest && editRequest
settings.currentSettings.partialRequestsEnabled && ? settings.currentSettings.partialRequestsEnabled &&
selectedSeasons.length === 0 selectedSeasons.length === 0
? 'danger' ? 'danger'
: `primary` : hasPermission(Permission.MANAGE_REQUESTS)
? 'success'
: 'primary'
: 'primary'
} }
cancelText={ cancelText={
editRequest editRequest
@@ -440,7 +471,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 +488,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 +499,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
} }
@@ -482,7 +513,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
<thead> <thead>
<tr> <tr>
<th <th
className={`w-16 px-4 py-3 bg-gray-500 ${ className={`w-16 bg-gray-500 px-4 py-3 ${
!settings.currentSettings.partialRequestsEnabled && !settings.currentSettings.partialRequestsEnabled &&
'hidden' 'hidden'
}`} }`}
@@ -497,7 +528,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleAllSeasons(); 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.remaining &&
quota.tv.limit && quota.tv.limit &&
quota.tv.remaining < unrequestedSeasons.length quota.tv.remaining < unrequestedSeasons.length
@@ -509,28 +540,28 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isAllSeasons() ? 'bg-indigo-500' : 'bg-gray-800' 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`}
></span> ></span>
<span <span
aria-hidden="true" aria-hidden="true"
className={`${ className={`${
isAllSeasons() ? 'translate-x-5' : 'translate-x-0' isAllSeasons() ? '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`} } absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</th> </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"> <th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.season)} {intl.formatMessage(messages.season)}
</th> </th>
<th className="px-5 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6"> <th className="bg-gray-500 px-5 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(messages.numberofepisodes)} {intl.formatMessage(messages.numberofepisodes)}
</th> </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"> <th className="bg-gray-500 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.status)} {intl.formatMessage(globalMessages.status)}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-gray-600 divide-y divide-gray-700"> <tbody className="divide-y divide-gray-700 bg-gray-600">
{data?.seasons {data?.seasons
.filter((season) => season.seasonNumber !== 0) .filter((season) => season.seasonNumber !== 0)
.map((season) => { .map((season) => {
@@ -546,7 +577,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
return ( return (
<tr key={`season-${season.id}`}> <tr key={`season-${season.id}`}>
<td <td
className={`px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap ${ className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
!settings.currentSettings !settings.currentSettings
.partialRequestsEnabled && 'hidden' .partialRequestsEnabled && 'hidden'
}`} }`}
@@ -568,7 +599,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleSeason(season.seasonNumber); 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 || mediaSeason ||
(quota?.tv.limit && (quota?.tv.limit &&
currentlyRemaining <= 0 && currentlyRemaining <= 0 &&
@@ -590,7 +621,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
isSelectedSeason(season.seasonNumber) isSelectedSeason(season.seasonNumber)
? 'bg-indigo-500' ? 'bg-indigo-500'
: 'bg-gray-800' : '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`}
></span> ></span>
<span <span
aria-hidden="true" aria-hidden="true"
@@ -603,21 +634,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
isSelectedSeason(season.seasonNumber) isSelectedSeason(season.seasonNumber)
? 'translate-x-5' ? 'translate-x-5'
: 'translate-x-0' : '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`} } absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span> ></span>
</span> </span>
</td> </td>
<td className="px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 whitespace-nowrap"> <td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
{season.seasonNumber === 0 {season.seasonNumber === 0
? intl.formatMessage(messages.extras) ? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, { : intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber, number: season.seasonNumber,
})} })}
</td> </td>
<td className="px-5 py-4 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap"> <td className="whitespace-nowrap px-5 py-4 text-sm leading-5 text-gray-200 md:px-6">
{season.episodeCount} {season.episodeCount}
</td> </td>
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap"> <td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
{!seasonRequest && !mediaSeason && ( {!seasonRequest && !mediaSeason && (
<Badge> <Badge>
{intl.formatMessage( {intl.formatMessage(
@@ -667,28 +698,26 @@ 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} isAnime={data?.keywords.some(
isAnime={data?.keywords.some( (keyword) => keyword.id === ANIME_KEYWORD_ID
(keyword) => keyword.id === ANIME_KEYWORD_ID )}
)} onChange={(overrides) => setRequestOverrides(overrides)}
onChange={(overrides) => setRequestOverrides(overrides)} requestUser={editRequest?.requestedBy}
requestUser={editRequest?.requestedBy} defaultOverrides={
defaultOverrides={ editRequest
editRequest ? {
? { folder: editRequest.rootFolder,
folder: editRequest.rootFolder, profile: editRequest.profileId,
profile: editRequest.profileId, server: editRequest.serverId,
server: editRequest.serverId, language: editRequest.languageProfileId,
language: editRequest.languageProfileId, tags: editRequest.tags,
tags: editRequest.tags, }
} : undefined
: undefined }
} />
/>
</div>
)} )}
</Modal> </Modal>
); );

View File

@@ -1,9 +1,9 @@
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 MovieRequestModal from './MovieRequestModal';
import TvRequestModal from './TvRequestModal';
interface RequestModalProps { interface RequestModalProps {
show: boolean; show: boolean;
@@ -26,29 +26,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,14 +36,25 @@ const RequestModal: React.FC<RequestModalProps> = ({
leaveTo="opacity-0" leaveTo="opacity-0"
show={show} show={show}
> >
<MovieRequestModal {type === 'movie' ? (
onComplete={onComplete} <MovieRequestModal
onCancel={onCancel} onComplete={onComplete}
tmdbId={tmdbId} onCancel={onCancel}
onUpdating={onUpdating} tmdbId={tmdbId}
is4k={is4k} onUpdating={onUpdating}
editRequest={editRequest} is4k={is4k}
/> editRequest={editRequest}
/>
) : (
<TvRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
onUpdating={onUpdating}
is4k={is4k}
editRequest={editRequest}
/>
)}
</Transition> </Transition>
); );
}; };

View File

@@ -13,8 +13,11 @@ interface StatusBadgeProps {
status?: MediaStatus; status?: MediaStatus;
is4k?: boolean; is4k?: boolean;
inProgress?: boolean; inProgress?: boolean;
plexUrl?: string;
tmdbId?: number;
mediaUrl?: string; mediaUrl?: string;
mediaUrl4k?: string; mediaUrl4k?: string;
mediaType?: 'movie' | 'tv';
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ const StatusBadge: React.FC<StatusBadgeProps> = ({