first commit

This commit is contained in:
Fallenbagel
2022-04-13 13:17:53 +05:00
parent f97ee11430
commit 754dccc4bf
4 changed files with 551 additions and 889 deletions

View File

@@ -1,38 +1,24 @@
import { import React, { useContext, useEffect } from 'react';
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import Link from 'next/link';
import React, { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv'; import type { TvDetails } from '../../../server/models/Tv';
import { Permission, useUser } from '../../hooks/useUser'; import type { MovieDetails } from '../../../server/models/Movie';
import globalMessages from '../../i18n/globalMessages'; import useSWR from 'swr';
import { withProperties } from '../../utils/typeHelpers'; import { LanguageContext } from '../../context/LanguageContext';
import { MediaRequestStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge'; import Badge from '../Common/Badge';
import { useUser, Permission } from '../../hooks/useUser';
import axios from 'axios';
import Button from '../Common/Button'; import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage'; import { withProperties } from '../../utils/typeHelpers';
import RequestModal from '../RequestModal'; import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
const messages = defineMessages({ const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}', seasons: 'Seasons',
failedretry: 'Something went wrong while retrying the request.', all: 'All',
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 => {
@@ -41,7 +27,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-xl w-72 sm:w-96 animate-pulse"> <div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
<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>
@@ -49,45 +35,6 @@ 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 p-4 bg-gray-800 ring-1 ring-red-500 rounded-xl w-72 sm:w-96">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center w-full h-full px-10">
<div className="w-full text-xs text-center text-gray-300 whitespace-normal 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;
@@ -98,16 +45,14 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
triggerOnce: true, triggerOnce: true,
}); });
const intl = useIntl(); const intl = useIntl();
const { user, hasPermission } = useUser(); const { hasPermission } = useUser();
const { addToast } = useToasts(); const { locale } = useContext(LanguageContext);
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}` : null inView ? `${url}?language=${locale}` : null
); );
const { const {
data: requestData, data: requestData,
@@ -125,30 +70,6 @@ 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);
@@ -164,242 +85,157 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
} }
if (!requestData && !requestError) { if (!requestData && !requestError) {
return <RequestCardError />; return <RequestCardPlaceholder />;
} }
if (!title || !requestData) { if (!title || !requestData) {
return <RequestCardError mediaId={requestData?.media.id} />; return <RequestCardPlaceholder />;
} }
return ( return (
<> <div
<RequestModal className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
show={showEditModal} style={{
tmdbId={request.media.tmdbId} 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})`,
type={request.type} }}
is4k={request.is4k} >
editRequest={request} <div className="flex flex-col flex-1 min-w-0 pr-4">
onCancel={() => setShowEditModal(false)} <h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
onComplete={() => { <Link
revalidate(); href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
setShowEditModal(false); as={
}} request.type === 'movie'
/> ? `/movie/${request.media.tmdbId}`
<div className="relative flex p-4 overflow-hidden text-gray-400 bg-gray-800 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700"> : `/tv/${request.media.tmdbId}`
{title.backdropPath && ( }
<div className="absolute inset-0 z-0"> >
<CachedImage {isMovie(title) ? title.title : title.name}
</Link>
</h2>
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt="" alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5"
layout="fill"
objectFit="cover"
/> />
<div <span className="text-xs truncate sm:text-sm group-hover:underline">
className="absolute inset-0" {requestData.requestedBy.displayName}
style={{ </span>
backgroundImage: </a>
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)', </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>
)} )}
<div className="relative z-10 flex flex-col flex-1 min-w-0 pr-4"> {request.seasons.length > 0 && (
<div className="hidden text-xs font-medium text-white sm:flex"> <div className="items-center hidden mt-2 text-sm sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( <span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
0, {!isMovie(title) &&
4 title.seasons.filter((season) => season.seasonNumber !== 0)
)} .length === request.seasons.length ? (
</div> <span className="mr-2 uppercase">
<Link <Badge>{intl.formatMessage(messages.all)}</Badge>
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="overflow-hidden text-base font-bold text-white sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
{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="flex items-center group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
)}
{!isMovie(title) && request.seasons.length > 0 && (
<div className="items-center my-0.5 sm:my-1 text-sm hidden 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> </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="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 className="flex items-center mt-2 text-sm sm:mt-1">
<span className="hidden mr-2 font-bold sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: intl.formatMessage(globalMessages.failed)}
</Badge>
) : ( ) : (
<StatusBadge <div className="overflow-x-scroll hide-scrollbar">
status={ {request.seasons.map((season) => (
requestData.media[requestData.is4k ? 'status4k' : 'status'] <span key={`season-${season.id}`} className="mr-2">
} <Badge>{season.seasonNumber}</Badge>
inProgress={ </span>
( ))}
requestData.media[ </div>
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ?? []
).length > 0
}
is4k={requestData.is4k}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
/>
)} )}
</div> </div>
<div className="flex items-end flex-1 space-x-2"> )}
{requestData.media[requestData.is4k ? 'status4k' : 'status'] === {requestData.status === MediaRequestStatus.PENDING &&
MediaStatus.UNKNOWN && hasPermission(Permission.MANAGE_REQUESTS) && (
requestData.status !== MediaRequestStatus.DECLINED && <div className="flex items-end flex-1">
hasPermission(Permission.MANAGE_REQUESTS) && ( <span className="mr-2">
<Button <Button
buttonType="primary" buttonType="success"
buttonSize="sm" buttonSize="sm"
disabled={isRetrying} onClick={() => modifyRequest('approve')}
onClick={() => retryRequest()}
> >
<RefreshIcon <svg
className={isRetrying ? 'animate-spin' : ''} className="w-4 h-4 mr-0 sm:mr-1"
style={{ marginRight: '0', animationDirection: 'reverse' }} fill="currentColor"
/> viewBox="0 0 20 20"
<span className="hidden ml-1.5 sm:block"> xmlns="http://www.w3.org/2000/svg"
{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>
{requestData.status === MediaRequestStatus.PENDING && <span>
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<CheckIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<XIcon style={{ marginRight: '0' }} />
<span className="hidden ml-1.5 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="hidden ml-1.5 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={() => deleteRequest()} onClick={() => modifyRequest('decline')}
> >
<XIcon style={{ marginRight: '0' }} /> <svg
<span className="hidden ml-1.5 sm:block"> className="w-4 h-4 mr-0 sm:mr-1"
{intl.formatMessage(globalMessages.cancel)} 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> </span>
</Button> </Button>
)} </span>
</div> </div>
</div> )}
</div>
<div className="flex-shrink-0 w-20 sm:w-28">
<Link <Link
href={ href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
as={
request.type === 'movie' request.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/movie/${request.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}` : `/tv/${request.media.tmdbId}`
} }
> >
<a className="flex-shrink-0 w-20 overflow-hidden transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"> <img
<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,88 +1,41 @@
import { import React, { useContext, useState } from 'react';
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import Link from 'next/link';
import React, { useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import { useToasts } from 'react-toast-notifications'; import {
useIntl,
FormattedDate,
FormattedRelativeTime,
defineMessages,
} from 'react-intl';
import { useUser, Permission } from '../../../hooks/useUser';
import { LanguageContext } from '../../../context/LanguageContext';
import type { MovieDetails } from '../../../../server/models/Movie';
import type { TvDetails } from '../../../../server/models/Tv';
import useSWR from 'swr'; import useSWR from 'swr';
import Badge from '../../Common/Badge';
import StatusBadge from '../../StatusBadge';
import Table from '../../Common/Table';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
} from '../../../../server/constants/media'; } 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 Badge from '../../Common/Badge';
import Button from '../../Common/Button'; import Button from '../../Common/Button';
import CachedImage from '../../Common/CachedImage'; import axios from 'axios';
import ConfirmButton from '../../Common/ConfirmButton'; import globalMessages from '../../../i18n/globalMessages';
import Link from 'next/link';
import { useToasts } from 'react-toast-notifications';
import RequestModal from '../../RequestModal'; import RequestModal from '../../RequestModal';
import StatusBadge from '../../StatusBadge';
const messages = defineMessages({ const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}', seasons: '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 flex-col items-center justify-center w-full h-64 px-10 bg-gray-800 lg:flex-row ring-1 ring-red-500 rounded-xl xl:h-32">
<span className="text-sm text-center 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;
@@ -97,14 +50,15 @@ const RequestItem: React.FC<RequestItemProps> = ({
}); });
const { addToast } = useToasts(); const { addToast } = useToasts();
const intl = useIntl(); const intl = useIntl();
const { user, hasPermission } = useUser(); const { 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}` : null inView ? `${url}?language=${locale}` : null
); );
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>( const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,
@@ -147,24 +101,22 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title && !error) { if (!title && !error) {
return ( return (
<div <tr className="w-full h-24 animate-pulse" ref={ref}>
className="w-full h-64 bg-gray-800 rounded-xl xl:h-32 animate-pulse" <td colSpan={6}></td>
ref={ref} </tr>
/>
); );
} }
if (!title || !requestData) { if (!title || !requestData) {
return ( return (
<RequestItemError <tr className="w-full h-24 animate-pulse">
mediaId={requestData?.media.id} <td colSpan={6}></td>
revalidateList={revalidateList} </tr>
/>
); );
} }
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}
@@ -177,26 +129,28 @@ const RequestItem: React.FC<RequestItemProps> = ({
setShowEditModal(false); setShowEditModal(false);
}} }}
/> />
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-32 xl:flex-row"> <Table.TD>
{title.backdropPath && ( <div className="flex items-center">
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3"> <Link
<CachedImage href={
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} request.type === 'movie'
alt="" ? `/movie/${request.media.tmdbId}`
layout="fill" : `/tv/${request.media.tmdbId}`
objectFit="cover" }
/> >
<div <a className="flex-shrink-0 hidden mr-4 sm:block">
className="absolute inset-0" <img
style={{ src={
backgroundImage: title.posterPath
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)', ? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
}} : '/images/overseerr_poster_not_found.png'
/> }
</div> alt=""
)} 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 flex-col justify-between w-full overflow-hidden sm:flex-row"> />
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0"> </a>
</Link>
<div className="flex-shrink overflow-hidden">
<Link <Link
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
@@ -204,285 +158,219 @@ const RequestItem: React.FC<RequestItemProps> = ({
: `/tv/${requestData.media.tmdbId}` : `/tv/${requestData.media.tmdbId}`
} }
> >
<a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md sm:w-14 transform-gpu hover:scale-105"> <a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
<CachedImage {isMovie(title) ? title.title : title.name}
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> </a>
</Link> </Link>
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4"> <Link href={`/users/${requestData.requestedBy.id}`}>
<div className="font-medium pt-0.5 sm:pt-1 text-xs text-white"> <a className="flex items-center mt-1">
{(isMovie(title) <img
? title.releaseDate src={requestData.requestedBy.avatar}
: title.firstAirDate alt=""
)?.slice(0, 4)} className="w-5 mr-2 rounded-full"
</div>
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-lg font-bold text-white truncate xl:text-xl hover:underline">
{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>
{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="flex overflow-x-scroll hide-scrollbar flex-nowrap">
{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 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden 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.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ||
requestData.status === MediaRequestStatus.DECLINED ? (
<Badge badgeType="danger">
{requestData.status === MediaRequestStatus.DECLINED
? intl.formatMessage(globalMessages.declined)
: 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}
plexUrl={requestData.media.plexUrl}
plexUrl4k={requestData.media.plexUrl4k}
/> />
)} <span className="text-sm hover:underline">
</div> {requestData.requestedBy.displayName}
<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 text-sm text-gray-300 truncate">
{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="flex items-center truncate group">
<img
src={requestData.requestedBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requesteddate)}
</span>
<span className="flex text-sm text-gray-300 truncate">
<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>
<span className="flex text-sm text-gray-300 truncate"> </a>
{intl.formatMessage(messages.modifieduserdate, { </Link>
date: ( {requestData.seasons.length > 0 && (
<FormattedRelativeTime <div className="items-center hidden mt-2 text-sm sm:flex">
value={Math.floor( <span className="mr-2">
(new Date(requestData.updatedAt).getTime() - {intl.formatMessage(messages.seasons)}
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${requestData.modifiedBy.id}`}>
<a className="flex items-center truncate group">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{requestData.modifiedBy.displayName}
</span>
</a>
</Link>
),
})}
</span> </span>
{requestData.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
</span>
))}
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 space-y-2 xl:mt-0 xl:items-end xl:w-96 xl:pl-0"> </Table.TD>
{requestData.media[requestData.is4k ? 'status4k' : 'status'] === <Table.TD>
MediaStatus.UNKNOWN && {requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
requestData.status !== MediaRequestStatus.DECLINED && MediaStatus.UNKNOWN ||
hasPermission(Permission.MANAGE_REQUESTS) && ( requestData.status === MediaRequestStatus.DECLINED ? (
<Button <Badge badgeType="danger">
className="w-full" {requestData.status === MediaRequestStatus.DECLINED
buttonType="primary" ? intl.formatMessage(globalMessages.declined)
disabled={isRetrying} : intl.formatMessage(globalMessages.failed)}
onClick={() => retryRequest()} </Badge>
> ) : (
<RefreshIcon <StatusBadge
className={isRetrying ? 'animate-spin' : ''} status={requestData.media[requestData.is4k ? 'status4k' : 'status']}
style={{ animationDirection: 'reverse' }} inProgress={
(
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> <span className="text-sm">
{intl.formatMessage( {requestData.modifiedBy.displayName} (
isRetrying ? globalMessages.retrying : globalMessages.retry <FormattedRelativeTime
)} value={Math.floor(
</span> (new Date(requestData.updatedAt).getTime() - Date.now()) /
</Button> 1000
)} )}
{requestData.status !== MediaRequestStatus.PENDING && updateIntervalInSeconds={1}
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 flex-row w-full 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 && ) : (
(hasPermission(Permission.MANAGE_REQUESTS) || <span className="text-sm text-gray-300">N/A</span>
(requestData.requestedBy.id === user?.id && )}
(requestData.type === 'tv' || </div>
hasPermission(Permission.REQUEST_ADVANCED)))) && ( </Table.TD>
<span className="w-full"> <Table.TD alignText="right">
{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 <Button
className="w-full" buttonType="success"
buttonType="primary" buttonSize="sm"
onClick={() => setShowEditModal(true)} onClick={() => modifyRequest('approve')}
> >
<PencilIcon /> <svg
<span>{intl.formatMessage(messages.editrequest)}</span> 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> </Button>
</span> </span>
)} <span className="mr-2">
{requestData.status === MediaRequestStatus.PENDING && <Button
!hasPermission(Permission.MANAGE_REQUESTS) && buttonType="danger"
requestData.requestedBy.id === user?.id && ( buttonSize="sm"
<ConfirmButton onClick={() => modifyRequest('decline')}
onClick={() => deleteRequest()} >
confirmText={intl.formatMessage(globalMessages.areyousure)} <svg
className="w-full" className="w-4 h-4 mr-0 sm:mr-1"
> fill="currentColor"
<XIcon /> viewBox="0 0 20 20"
<span>{intl.formatMessage(messages.cancelRequest)}</span> xmlns="http://www.w3.org/2000/svg"
</ConfirmButton> >
)} <path
</div> fillRule="evenodd"
</div> 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
buttonType="primary"
buttonSize="sm"
onClick={() => setShowEditModal(true)}
>
<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 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>
</span>
</>
)}
</Table.TD>
</tr>
); );
}; };

View File

@@ -1,94 +1,51 @@
import { import React, { useState } from 'react';
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 { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import Header from '../Common/Header';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import RequestItem from './RequestItem'; import RequestItem from './RequestItem';
import Header from '../Common/Header';
import Table from '../Common/Table';
import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import PageTitle from '../Common/PageTitle';
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: 'Request Date',
sortModified: 'Last Modified', sortModified: 'Last Modified',
}); });
enum Filter { type Filter = 'all' | 'pending' | 'approved' | 'processing' | 'available';
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 { user } = useUser({ const [pageIndex, setPageIndex] = useState(0);
id: Number(router.query.userId), const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
});
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 page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const { data, error, revalidate } = useSWR<RequestResultsResponse>( const { data, error, 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 />;
} }
@@ -102,81 +59,73 @@ const RequestList: React.FC = () => {
return ( return (
<> <>
<PageTitle <PageTitle title={intl.formatMessage(messages.requests)} />
title={[ <div className="flex flex-col justify-between lg:items-end lg:flex-row">
intl.formatMessage(messages.requests), <Header>{intl.formatMessage(messages.requests)}</Header>
router.query.userId ? user?.displayName : '',
]}
/>
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
<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="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0"> <div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0"> <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"> <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">
<FilterIcon className="w-6 h-6" /> <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>
</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(globalMessages.all)} {intl.formatMessage(messages.filterAll)}
</option> </option>
<option value="pending"> <option value="pending">
{intl.formatMessage(globalMessages.pending)} {intl.formatMessage(messages.filterPending)}
</option> </option>
<option value="approved"> <option value="approved">
{intl.formatMessage(globalMessages.approved)} {intl.formatMessage(messages.filterApproved)}
</option> </option>
<option value="processing"> <option value="processing">
{intl.formatMessage(globalMessages.processing)} {intl.formatMessage(messages.filterProcessing)}
</option> </option>
<option value="available"> <option value="available">
{intl.formatMessage(globalMessages.available)} {intl.formatMessage(messages.filterAvailable)}
</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="flex flex-grow mb-2 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 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">
<SortDescendingIcon className="w-6 h-6" /> <svg
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"
@@ -191,104 +140,114 @@ const RequestList: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
{data.results.map((request) => { <Table>
return ( <thead>
<div className="py-2" key={`request-list-${request.id}`}> <tr>
<RequestItem <Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
request={request} <Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
revalidateList={() => revalidate()} <Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
/> <Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
</div> <Table.TH></Table.TH>
); </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 && (
<div className="flex flex-col items-center justify-center w-full py-24 text-white"> <tr className="relative h-24 p-2 text-white">
<span className="text-2xl text-gray-400"> <Table.TD colSpan={6} noPadding>
{intl.formatMessage(globalMessages.noresults)} <div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full">
</span> <span className="text-base">
{currentFilter !== Filter.ALL && ( {intl.formatMessage(messages.noresults)}
<div className="mt-4"> </span>
<Button {currentFilter !== 'all' && (
buttonType="primary" <div className="mt-4">
onClick={() => setCurrentFilter(Filter.ALL)} <Button
> buttonSize="sm"
{intl.formatMessage(messages.showallrequests)} buttonType="primary"
</Button> onClick={() => setCurrentFilter('all')}
</div> >
{intl.formatMessage(messages.showallrequests)}
</Button>
</div>
)}
</div>
</Table.TD>
</tr>
)} )}
</div> <tr className="bg-gray-700">
)} <Table.TD colSpan={6} noPadding>
<div className="actions"> <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="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row" 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 ? pageIndex * currentPageSize + data.results.length
? pageIndex * currentPageSize + data.results.length : (pageIndex + 1) * currentPageSize,
: (pageIndex + 1) * currentPageSize, total: data.pageInfo.results,
total: data.pageInfo.results, strong: function strong(msg) {
strong: function strong(msg) { return <span className="font-medium">{msg}</span>;
return <span className="font-medium">{msg}</span>; },
}, })}
})} </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="items-center -mt-3 text-sm truncate 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({ value={currentPageSize}
pathname: router.pathname, className="inline short"
query: router.query.userId >
? { userId: router.query.userId } <option value="5">5</option>
: {}, <option value="10">10</option>
}) <option value="25">25</option>
.then(() => window.scrollTo(0, 0)); <option value="50">50</option>
}} <option value="100">100</option>
value={currentPageSize} </select>
className="inline short" ),
})}
</span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<Button
disabled={!hasPrevPage}
onClick={() => setPageIndex((current) => current - 1)}
> >
<option value="5">5</option> {intl.formatMessage(messages.previous)}
<option value="10">10</option> </Button>
<option value="25">25</option> <Button
<option value="50">50</option> disabled={!hasNextPage}
<option value="100">100</option> onClick={() => setPageIndex((current) => current + 1)}
</select> >
), {intl.formatMessage(messages.next)}
})} </Button>
</span> </div>
</div> </nav>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1"> </Table.TD>
<Button </tr>
disabled={!hasPrevPage} </Table.TBody>
onClick={() => updateQueryParams('page', (page - 1).toString())} </Table>
>
<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

@@ -27,28 +27,7 @@ const StatusChecker: React.FC = () => {
return null; return null;
} }
return ( return null;
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear
show={data.commitTag !== process.env.commitTag}
>
<Modal
iconSvg={<SparklesIcon />}
title={intl.formatMessage(messages.newversionavailable)}
onOk={() => location.reload()}
okText={intl.formatMessage(messages.reloadOverseerr)}
backgroundClickable={false}
>
{intl.formatMessage(messages.newversionDescription)}
</Modal>
</Transition>
);
}; };
export default StatusChecker; export default StatusChecker;