mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(requests): add request quotas (#1277)
* feat(quotas): rebased * feat: add getQuota() method to User entity * feat(ui): add default quota setting options * feat: user quota settings * feat: quota display in request modals * fix: only show user quotas on own profile or with manage users permission * feat: add request progress circles to profile page * feat: add migration * fix: add missing restricted field to api schema * fix: dont show auto approve message for movie request when restricted * fix(lang): change enable checkbox langauge to "enable override" Co-authored-by: Jakob Ankarhem <jakob.ankarhem@outlook.com> Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
This commit is contained in:
@@ -1,22 +1,33 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import Error from '../../pages/_error';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import { UserRequestsResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||
import Slider from '../Slider';
|
||||
import RequestCard from '../RequestCard';
|
||||
import {
|
||||
QuotaResponse,
|
||||
UserRequestsResponse,
|
||||
} from '../../../server/interfaces/api/userInterfaces';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import Error from '../../pages/_error';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import ProgressCircle from '../Common/ProgressCircle';
|
||||
import RequestCard from '../RequestCard';
|
||||
import Slider from '../Slider';
|
||||
import ProfileHeader from './ProfileHeader';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
recentrequests: 'Recent Requests',
|
||||
norequests: 'No Requests',
|
||||
limit: '{remaining} of {limit}',
|
||||
requestsperdays: '{limit} remaining',
|
||||
unlimited: 'Unlimited',
|
||||
totalrequests: 'Total Requests',
|
||||
pastdays: '{type} (past {days} days)',
|
||||
movierequests: 'Movie Requests',
|
||||
seriesrequest: 'Series Requests',
|
||||
});
|
||||
|
||||
type MediaTitle = MovieDetails | TvDetails;
|
||||
@@ -27,6 +38,7 @@ const UserProfile: React.FC = () => {
|
||||
const { user, error } = useUser({
|
||||
id: Number(router.query.userId),
|
||||
});
|
||||
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
|
||||
const [availableTitles, setAvailableTitles] = useState<
|
||||
Record<number, MediaTitle>
|
||||
>({});
|
||||
@@ -34,6 +46,9 @@ const UserProfile: React.FC = () => {
|
||||
const { data: requests, error: requestError } = useSWR<UserRequestsResponse>(
|
||||
user ? `/api/v1/user/${user?.id}/requests?take=10&skip=0` : null
|
||||
);
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${user.id}/quota` : null
|
||||
);
|
||||
|
||||
const updateAvailableTitles = useCallback(
|
||||
(requestId: number, mediaTitle: MediaTitle) => {
|
||||
@@ -76,6 +91,140 @@ const UserProfile: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<ProfileHeader user={user} />
|
||||
{quota &&
|
||||
(user.id === currentUser?.id ||
|
||||
currentHasPermission(Permission.MANAGE_USERS)) && (
|
||||
<div className="relative z-40">
|
||||
<dl className="grid grid-cols-1 gap-5 mt-5 lg:grid-cols-3">
|
||||
<div className="px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ring-gray-700 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-300 truncate">
|
||||
{intl.formatMessage(messages.totalrequests)}
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-white">
|
||||
{intl.formatNumber(user.requestCount)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${
|
||||
quota.movie.restricted
|
||||
? 'ring-red-500 from-red-900 to-transparent bg-gradient-to-t'
|
||||
: 'ring-gray-700'
|
||||
} sm:p-6`}
|
||||
>
|
||||
<dt
|
||||
className={`text-sm font-medium truncate ${
|
||||
quota.movie.restricted ? 'text-red-500' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{quota.tv.limit
|
||||
? intl.formatMessage(messages.pastdays, {
|
||||
type: intl.formatMessage(messages.movierequests),
|
||||
days: quota?.movie.days,
|
||||
})
|
||||
: intl.formatMessage(messages.movierequests)}
|
||||
</dt>
|
||||
<dd
|
||||
className={`flex mt-1 text-sm items-center ${
|
||||
quota.movie.restricted ? 'text-red-500' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{quota.movie.limit ? (
|
||||
<>
|
||||
<ProgressCircle
|
||||
progress={Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((quota?.movie.remaining ?? 0) /
|
||||
(quota?.movie.limit ?? 1)) *
|
||||
100
|
||||
)
|
||||
)}
|
||||
useHeatLevel
|
||||
className="w-8 h-8 mr-2"
|
||||
/>
|
||||
<div>
|
||||
{intl.formatMessage(messages.requestsperdays, {
|
||||
limit: (
|
||||
<span className="text-3xl font-semibold">
|
||||
{intl.formatMessage(messages.limit, {
|
||||
remaining: quota.movie.remaining,
|
||||
limit: quota.movie.limit,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-3xl">
|
||||
{intl.formatMessage(messages.unlimited)}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`px-4 py-5 overflow-hidden bg-gray-800 bg-opacity-50 rounded-lg shadow ring-1 ${
|
||||
quota.tv.restricted
|
||||
? 'ring-red-500 from-red-900 to-transparent bg-gradient-to-t'
|
||||
: 'ring-gray-700'
|
||||
} sm:p-6`}
|
||||
>
|
||||
<dt
|
||||
className={`text-sm font-medium truncate ${
|
||||
quota.tv.restricted ? 'text-red-500' : 'text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{quota.tv.limit
|
||||
? intl.formatMessage(messages.pastdays, {
|
||||
type: intl.formatMessage(messages.seriesrequest),
|
||||
days: quota?.tv.days,
|
||||
})
|
||||
: intl.formatMessage(messages.seriesrequest)}
|
||||
</dt>
|
||||
<dd
|
||||
className={`flex items-center mt-1 text-sm ${
|
||||
quota.tv.restricted ? 'text-red-500' : 'text-white'
|
||||
}`}
|
||||
>
|
||||
{quota.tv.limit ? (
|
||||
<>
|
||||
<ProgressCircle
|
||||
progress={Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((quota?.tv.remaining ?? 0) /
|
||||
(quota?.tv.limit ?? 1)) *
|
||||
100
|
||||
)
|
||||
)}
|
||||
useHeatLevel
|
||||
className="w-8 h-8 mr-2"
|
||||
/>
|
||||
<div>
|
||||
{intl.formatMessage(messages.requestsperdays, {
|
||||
limit: (
|
||||
<span className="text-3xl font-semibold">
|
||||
{intl.formatMessage(messages.limit, {
|
||||
remaining: quota.tv.remaining,
|
||||
limit: quota.tv.limit,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-3xl">
|
||||
{intl.formatMessage(messages.unlimited)}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-40 mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 cursor-default sm:text-2xl sm:leading-9 sm:truncate">
|
||||
|
Reference in New Issue
Block a user