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,75 +164,100 @@ 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}
editRequest={request}
onCancel={() => setShowEditModal(false)}
onComplete={() => {
revalidate();
setShowEditModal(false);
}} }}
> />
<div className="flex flex-col flex-1 min-w-0 pr-4"> <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">
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline"> {title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)}
<div className="relative z-10 flex min-w-0 flex-1 flex-col pr-4">
<div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
4
)}
</div>
<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}`
} }
> >
<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} {isMovie(title) ? title.title : title.name}
</a>
</Link> </Link>
</h2> {hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) && (
<div className="card-field">
<Link href={`/users/${requestData.requestedBy.id}`}> <Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group"> <a className="group flex items-center">
<img <img
src={requestData.requestedBy.avatar} src={requestData.requestedBy.avatar}
alt="" alt=""
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5" className="avatar-sm"
/> />
<span className="text-xs truncate sm:text-sm group-hover:underline"> <span className="truncate font-semibold group-hover:text-white group-hover:underline">
{requestData.requestedBy.displayName} {requestData.requestedBy.displayName}
</span> </span>
</a> </a>
</Link> </Link>
{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 && ( {!isMovie(title) && request.seasons.length > 0 && (
<div className="items-center hidden mt-2 text-sm sm:flex"> <div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span> <span className="mr-2 font-bold ">
{!isMovie(title) && {intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter((season) => season.seasonNumber !== 0) 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 ? ( .length === request.seasons.length ? (
<span className="mr-2 uppercase"> <span className="mr-2 uppercase">
<Badge>{intl.formatMessage(messages.all)}</Badge> <Badge>{intl.formatMessage(globalMessages.all)}</Badge>
</span> </span>
) : ( ) : (
<div className="overflow-x-scroll hide-scrollbar"> <div className="hide-scrollbar overflow-x-scroll">
{request.seasons.map((season) => ( {request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2"> <span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge> <Badge>{season.seasonNumber}</Badge>
@@ -163,79 +267,148 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
)} )}
</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
buttonType="primary"
buttonSize="sm"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<RefreshIcon
className={isRetrying ? 'animate-spin' : ''}
style={{ marginRight: '0', animationDirection: 'reverse' }}
/>
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
)}
{requestData.status === MediaRequestStatus.PENDING && {requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex items-end flex-1"> <>
<span className="mr-2">
<Button <Button
buttonType="success" buttonType="success"
buttonSize="sm" buttonSize="sm"
onClick={() => modifyRequest('approve')} onClick={() => modifyRequest('approve')}
> >
<svg <CheckIcon style={{ marginRight: '0' }} />
className="w-4 h-4 mr-0 sm:mr-1" <span className="ml-1.5 hidden sm:block">
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)} {intl.formatMessage(globalMessages.approve)}
</span> </span>
</Button> </Button>
</span>
<span>
<Button <Button
buttonType="danger" buttonType="danger"
buttonSize="sm" buttonSize="sm"
onClick={() => modifyRequest('decline')} onClick={() => modifyRequest('decline')}
> >
<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"
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)} {intl.formatMessage(globalMessages.decline)}
</span> </span>
</Button> </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> </span>
</div> </Button>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<XIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.cancel)}
</span>
</Button>
)} )}
</div> </div>
<div className="flex-shrink-0 w-20 sm:w-28"> </div>
<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">
<CachedImage
src={ src={
title.posterPath title.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png' : '/images/overseerr_poster_not_found.png'
} }
alt="" 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" 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 { const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
data: requestData, `/api/v1/request/${request.id}`,
revalidate, {
mutate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
initialData: request, 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}`
: `/tv/${request.media.tmdbId}`
}
>
<a className="flex-shrink-0 hidden mr-4 sm:block">
<img
src={
title.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt="" alt=""
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md" layout="fill"
objectFit="cover"
/> />
</a> <div
</Link> className="absolute inset-0"
<div className="flex-shrink overflow-hidden"> style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<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 <Link
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
@@ -159,28 +204,59 @@ 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">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
objectFit="cover"
/>
</a>
</Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<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} {isMovie(title) ? title.title : title.name}
</a> </a>
</Link> </Link>
<Link href={`/users/${requestData.requestedBy.id}`}> {!isMovie(title) && request.seasons.length > 0 && (
<a className="flex items-center mt-1"> <div className="card-field">
<img <span className="card-field-name">
src={requestData.requestedBy.avatar} {intl.formatMessage(messages.seasons, {
alt="" seasonCount:
className="w-5 mr-2 rounded-full" title.seasons.filter(
/> (season) => season.seasonNumber !== 0
<span className="text-sm hover:underline"> ).length === request.seasons.length
{requestData.requestedBy.displayName} ? 0
: request.seasons.length,
})}
</span> </span>
</a> {title.seasons.filter((season) => season.seasonNumber !== 0)
</Link> .length === request.seasons.length ? (
{requestData.seasons.length > 0 && ( <span className="mr-2 uppercase">
<div className="items-center hidden mt-2 text-sm sm:flex"> <Badge>{intl.formatMessage(globalMessages.all)}</Badge>
<span className="mr-2">
{intl.formatMessage(messages.seasons)}
</span> </span>
{requestData.seasons.map((season) => ( ) : (
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2"> <span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge> <Badge>{season.seasonNumber}</Badge>
</span> </span>
@@ -188,20 +264,32 @@ const RequestItem: React.FC<RequestItemProps> = ({
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</Table.TD> </div>
<Table.TD> <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">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] === <div className="card-field">
MediaStatus.UNKNOWN || <span className="card-field-name">
requestData.status === MediaRequestStatus.DECLINED ? ( {intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger"> <Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED {intl.formatMessage(globalMessages.declined)}
? intl.formatMessage(globalMessages.declined) </Badge>
: intl.formatMessage(globalMessages.failed)} ) : requestData.media[
requestData.is4k ? 'status4k' : 'status'
] === MediaStatus.UNKNOWN ? (
<Badge
badgeType="danger"
//href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
>
{intl.formatMessage(globalMessages.failed)}
</Badge> </Badge>
) : ( ) : (
<StatusBadge <StatusBadge
status={requestData.media[requestData.is4k ? 'status4k' : 'status']} status={
requestData.media[requestData.is4k ? 'status4k' : 'status']
}
inProgress={ inProgress={
( (
requestData.media[ requestData.media[
@@ -210,168 +298,201 @@ const RequestItem: React.FC<RequestItemProps> = ({
).length > 0 ).length > 0
} }
is4k={requestData.is4k} is4k={requestData.is4k}
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/> />
)} )}
</Table.TD>
<Table.TD>
<div className="flex flex-col">
<span className="text-sm text-gray-300">
<FormattedDate value={requestData.createdAt} />
</span>
</div> </div>
</Table.TD> <div className="card-field">
<Table.TD> {hasPermission(
<div className="flex flex-col"> [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{requestData.modifiedBy ? ( { type: 'or' }
<span className="text-sm text-gray-300"> ) ? (
<div className="flex items-center"> <>
<img <span className="card-field-name">
src={requestData.modifiedBy.avatar} {intl.formatMessage(messages.requested)}
alt="" </span>
className="w-5 mr-2 rounded-full" <span className="flex truncate text-sm text-gray-300">
/> {intl.formatMessage(messages.modifieduserdate, {
<span className="text-sm"> date: (
{requestData.modifiedBy.displayName} (
<FormattedRelativeTime <FormattedRelativeTime
value={Math.floor( value={Math.floor(
(new Date(requestData.updatedAt).getTime() - Date.now()) / (new Date(requestData.createdAt).getTime() -
Date.now()) /
1000 1000
)} )}
updateIntervalInSeconds={1} 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> </span>
</div> </a>
</Link>
),
})}
</span> </span>
</>
) : ( ) : (
<span className="text-sm text-gray-300">N/A</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> </div>
</Table.TD> {requestData.modifiedBy && (
<Table.TD alignText="right"> <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 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">
{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) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <Button
className="mr-2" className="w-full"
buttonType="primary" buttonType="primary"
buttonSize="sm"
disabled={isRetrying} disabled={isRetrying}
onClick={() => retryRequest()} onClick={() => retryRequest()}
> >
<svg <RefreshIcon
className="w-4 h-4 mr-0 sm:mr-1" className={isRetrying ? 'animate-spin' : ''}
fill="currentColor" style={{ animationDirection: 'reverse' }}
xmlns="http://www.w3.org/2000/svg" />
viewBox="0 0 24 24" <span>
width="18px" {intl.formatMessage(
height="18px" isRetrying ? globalMessages.retrying : globalMessages.retry
> )}
<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> </span>
</Button> </Button>
)} )}
{requestData.status !== MediaRequestStatus.PENDING && {requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <ConfirmButton
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()} onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
> >
<svg <TrashIcon />
className="w-4 h-4 mr-0 sm:mr-1" <span>{intl.formatMessage(messages.deleterequest)}</span>
fill="currentColor" </ConfirmButton>
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 && {requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<> <div className="flex w-full flex-row space-x-2">
<span className="mr-2"> <span className="w-full">
<Button <Button
className="w-full"
buttonType="success" buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')} onClick={() => modifyRequest('approve')}
> >
<svg <CheckIcon />
className="w-4 h-4 mr-0 sm:mr-1" <span>{intl.formatMessage(globalMessages.approve)}</span>
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> </Button>
</span> </span>
<span className="mr-2"> <span className="w-full">
<Button <Button
className="w-full"
buttonType="danger" buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')} onClick={() => modifyRequest('decline')}
> >
<svg <XIcon />
className="w-4 h-4 mr-0 sm:mr-1" <span>{intl.formatMessage(globalMessages.decline)}</span>
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> </Button>
</span> </span>
<span> </div>
)}
{requestData.status === MediaRequestStatus.PENDING &&
(hasPermission(Permission.MANAGE_REQUESTS) ||
(requestData.requestedBy.id === user?.id &&
(requestData.type === 'tv' ||
hasPermission(Permission.REQUEST_ADVANCED)))) && (
<span className="w-full">
<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>
</>
)} )}
</Table.TD> {requestData.status === MediaRequestStatus.PENDING &&
</tr> !hasPermission(Permission.MANAGE_REQUESTS) &&
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
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
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> <div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header
subtext={
router.query.userId ? (
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
) : (
''
)
}
>
{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,59 +195,43 @@ const RequestList: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
<Table.TH></Table.TH>
</tr>
</thead>
<Table.TBody>
{data.results.map((request) => { {data.results.map((request) => {
return ( return (
<div className="py-2" key={`request-list-${request.id}`}>
<RequestItem <RequestItem
request={request} request={request}
key={`request-list-${request.id}`}
revalidateList={() => revalidate()} revalidateList={() => revalidate()}
/> />
</div>
); );
})} })}
{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">
{intl.formatMessage(messages.noresults)}
</span> </span>
{currentFilter !== 'all' && ( {currentFilter !== Filter.ALL && (
<div className="mt-4"> <div className="mt-4">
<Button <Button
buttonSize="sm"
buttonType="primary" buttonType="primary"
onClick={() => setCurrentFilter('all')} onClick={() => setCurrentFilter(Filter.ALL)}
> >
{intl.formatMessage(messages.showallrequests)} {intl.formatMessage(messages.showallrequests)}
</Button> </Button>
</div> </div>
)} )}
</div> </div>
</Table.TD>
</tr>
)} )}
<tr className="bg-gray-700"> <div className="actions">
<Table.TD colSpan={6} noPadding>
<nav <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" className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
aria-label="Pagination" aria-label="Pagination"
> >
<div className="hidden lg:flex lg:flex-1"> <div className="hidden lg:flex lg:flex-1">
<p className="text-sm"> <p className="text-sm">
{data.results.length > 0 && {data.results.length > 0 &&
intl.formatMessage(messages.showingresults, { intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1, from: pageIndex * currentPageSize + 1,
to: to:
data.results.length < currentPageSize data.results.length < currentPageSize
@@ -206,18 +245,25 @@ const RequestList: React.FC = () => {
</p> </p>
</div> </div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center"> <div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0"> <span className="-mt-3 items-center truncate text-sm sm:mt-0">
{intl.formatMessage(messages.resultsperpage, { {intl.formatMessage(globalMessages.resultsperpage, {
pageSize: ( pageSize: (
<select <select
id="pageSize" id="pageSize"
name="pageSize" name="pageSize"
onChange={(e) => { onChange={(e) => {
setPageIndex(0);
setCurrentPageSize(Number(e.target.value)); setCurrentPageSize(Number(e.target.value));
router
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}} }}
value={currentPageSize} value={currentPageSize}
className="inline short" className="short inline"
> >
<option value="5">5</option> <option value="5">5</option>
<option value="10">10</option> <option value="10">10</option>
@@ -229,25 +275,24 @@ const RequestList: React.FC = () => {
})} })}
</span> </span>
</div> </div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1"> <div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button <Button
disabled={!hasPrevPage} disabled={!hasPrevPage}
onClick={() => setPageIndex((current) => current - 1)} onClick={() => updateQueryParams('page', (page - 1).toString())}
> >
{intl.formatMessage(messages.previous)} <ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button> </Button>
<Button <Button
disabled={!hasNextPage} disabled={!hasNextPage}
onClick={() => setPageIndex((current) => current + 1)} onClick={() => updateQueryParams('page', (page + 1).toString())}
> >
{intl.formatMessage(messages.next)} <span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button> </Button>
</div> </div>
</nav> </nav>
</Table.TD> </div>
</tr>
</Table.TBody>
</Table>
</> </>
); );
}; };

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(
alsoApproveRequest
? messages.requestApproved
: messages.requestedited,
{
title: data?.title, title: data?.title,
strong: function strong(msg) { strong: function strong(msg) {
return <strong>{msg}</strong>; 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,7 +276,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
})} })}
{(hasPermission(Permission.REQUEST_ADVANCED) || {(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && ( hasPermission(Permission.MANAGE_REQUESTS)) && (
<div className="mt-4">
<AdvancedRequester <AdvancedRequester
type="movie" type="movie"
is4k={is4k} is4k={is4k}
@@ -259,7 +290,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
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(
alsoApproveRequest
? messages.requestApproved
: messages.requestedited,
{
title: data?.name, title: data?.name,
strong: function strong(msg) { strong: function strong(msg) {
return <strong>{msg}</strong>; 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(
is4k ? messages.requestseasons4k : messages.requestseasons,
{
seasonCount: selectedSeasons.length, 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,7 +698,6 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
</div> </div>
{(hasPermission(Permission.REQUEST_ADVANCED) || {(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && ( hasPermission(Permission.MANAGE_REQUESTS)) && (
<div className="mt-4">
<AdvancedRequester <AdvancedRequester
type="tv" type="tv"
is4k={is4k} is4k={is4k}
@@ -688,7 +718,6 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
: 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,6 +36,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
leaveTo="opacity-0" leaveTo="opacity-0"
show={show} show={show}
> >
{type === 'movie' ? (
<MovieRequestModal <MovieRequestModal
onComplete={onComplete} onComplete={onComplete}
onCancel={onCancel} onCancel={onCancel}
@@ -67,6 +45,16 @@ const RequestModal: React.FC<RequestModalProps> = ({
is4k={is4k} is4k={is4k}
editRequest={editRequest} 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> = ({