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:
sct
2021-03-24 19:26:13 +09:00
committed by GitHub
parent a65e3d5bb6
commit 6c75c88228
24 changed files with 1212 additions and 145 deletions

View File

@@ -8,8 +8,6 @@ const messages = defineMessages({
settings: 'Edit Settings',
profile: 'View Profile',
joindate: 'Joined {joindate}',
requests:
'{requestCount} {requestCount, plural, one {Request} other {Requests}}',
userid: 'User ID: {userid}',
});
@@ -33,9 +31,6 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
day: 'numeric',
}),
}),
intl.formatMessage(messages.requests, {
requestCount: user.requestCount,
}),
];
if (hasPermission(Permission.MANAGE_REQUESTS)) {

View File

@@ -1,20 +1,22 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import { Language } from '../../../../../server/lib/settings';
import useSettings from '../../../../hooks/useSettings';
import { UserType, useUser, Permission } from '../../../../hooks/useUser';
import { Permission, UserType, useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages';
import Error from '../../../../pages/_error';
import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import RegionSelector from '../../../RegionSelector';
import globalMessages from '../../../../i18n/globalMessages';
import PageTitle from '../../../Common/PageTitle';
import QuotaSelector from '../../../QuotaSelector';
import RegionSelector from '../../../RegionSelector';
const messages = defineMessages({
general: 'General',
@@ -37,21 +39,25 @@ const messages = defineMessages({
originallanguageTip: 'Filter content by original language',
originalLanguageDefault: 'All Languages',
languageServerDefault: 'Default ({language})',
movierequestlimit: 'Movie Request Limit',
seriesrequestlimit: 'Series Request Limit',
enableOverride: 'Enable Override',
});
const UserGeneralSettings: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false);
const router = useRouter();
const { user, hasPermission, mutate } = useUser({
id: Number(router.query.userId),
});
const { hasPermission: currentHasPermission } = useUser();
const { currentSettings } = useSettings();
const { data, error, revalidate } = useSWR<{
username?: string;
region?: string;
originalLanguage?: string;
}>(user ? `/api/v1/user/${user?.id}/settings/main` : null);
const { data, error, revalidate } = useSWR<UserSettingsGeneralResponse>(
user ? `/api/v1/user/${user?.id}/settings/main` : null
);
const { data: languages, error: languagesError } = useSWR<Language[]>(
'/api/v1/languages'
@@ -111,6 +117,10 @@ const UserGeneralSettings: React.FC = () => {
displayName: data?.username,
region: data?.region,
originalLanguage: data?.originalLanguage,
movieQuotaLimit: data?.movieQuotaLimit,
movieQuotaDays: data?.movieQuotaDays,
tvQuotaLimit: data?.tvQuotaLimit,
tvQuotaDays: data?.tvQuotaDays,
}}
enableReinitialize
onSubmit={async (values) => {
@@ -119,6 +129,12 @@ const UserGeneralSettings: React.FC = () => {
username: values.displayName,
region: values.region,
originalLanguage: values.originalLanguage,
movieQuotaLimit: movieQuotaEnabled
? values.movieQuotaLimit
: null,
movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null,
tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null,
tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null,
});
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
@@ -252,6 +268,91 @@ const UserGeneralSettings: React.FC = () => {
</div>
</div>
</div>
{currentHasPermission(Permission.MANAGE_USERS) &&
!hasPermission(Permission.MANAGE_USERS) && (
<>
<div className="form-row">
<label htmlFor="movieQuotaLimit" className="text-label">
<span>
{intl.formatMessage(messages.movierequestlimit)}
</span>
</label>
<div className="form-input">
<div className="flex flex-col">
<div className="flex items-center mb-4">
<input
type="checkbox"
checked={movieQuotaEnabled}
onChange={() => setMovieQuotaEnabled((s) => !s)}
/>
<span className="ml-2 text-gray-300">
{intl.formatMessage(messages.enableOverride)}
</span>
</div>
<QuotaSelector
isDisabled={!movieQuotaEnabled}
dayFieldName="movieQuotaDays"
limitFieldName="movieQuotaLimit"
mediaType="movie"
onChange={setFieldValue}
defaultDays={values.movieQuotaDays}
defaultLimit={values.movieQuotaLimit}
dayOverride={
!movieQuotaEnabled
? data?.globalMovieQuotaDays
: undefined
}
limitOverride={
!movieQuotaEnabled
? data?.globalMovieQuotaLimit
: undefined
}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="tvQuotaLimit" className="text-label">
<span>
{intl.formatMessage(messages.seriesrequestlimit)}
</span>
</label>
<div className="form-input">
<div className="flex flex-col">
<div className="flex items-center mb-4">
<input
type="checkbox"
checked={tvQuotaEnabled}
onChange={() => setTvQuotaEnabled((s) => !s)}
/>
<span className="ml-2 text-gray-300">
{intl.formatMessage(messages.enableOverride)}
</span>
</div>
<QuotaSelector
isDisabled={!tvQuotaEnabled}
dayFieldName="tvQuotaDays"
limitFieldName="tvQuotaLimit"
mediaType="tv"
onChange={setFieldValue}
defaultDays={values.tvQuotaDays}
defaultLimit={values.tvQuotaLimit}
dayOverride={
!tvQuotaEnabled
? data?.globalTvQuotaDays
: undefined
}
limitOverride={
!tvQuotaEnabled
? data?.globalTvQuotaLimit
: undefined
}
/>
</div>
</div>
</div>
</>
)}
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">

View File

@@ -2,15 +2,15 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { hasPermission, Permission } from '../../../../server/lib/permissions';
import useSettings from '../../../hooks/useSettings';
import { useUser } from '../../../hooks/useUser';
import { Permission, hasPermission } from '../../../../server/lib/permissions';
import globalMessages from '../../../i18n/globalMessages';
import Error from '../../../pages/_error';
import Alert from '../../Common/Alert';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle';
import ProfileHeader from '../ProfileHeader';
import useSettings from '../../../hooks/useSettings';
import Alert from '../../Common/Alert';
import globalMessages from '../../../i18n/globalMessages';
const messages = defineMessages({
menuGeneralSettings: 'General',

View File

@@ -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">