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

@@ -1,7 +1,7 @@
import React from 'react';
interface AlertProps {
title: string;
title?: React.ReactNode;
type?: 'warning' | 'info' | 'error';
}
@@ -77,14 +77,20 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
}
return (
<div className={`rounded-md p-4 mb-5 ${design.bgColor}`}>
<div className={`rounded-md p-4 mb-4 ${design.bgColor}`}>
<div className="flex">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3">
<div className={`text-sm font-medium ${design.titleColor}`}>
{title}
</div>
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
{title && (
<div className={`text-sm font-medium ${design.titleColor}`}>
{title}
</div>
)}
{children && (
<div className={`mt-2 first:mt-0 text-sm ${design.textColor}`}>
{children}
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
import React, { useEffect, useRef } from 'react';
interface ProgressCircleProps {
className?: string;
progress?: number;
useHeatLevel?: boolean;
}
const ProgressCircle: React.FC<ProgressCircleProps> = ({
className,
progress = 0,
useHeatLevel,
}) => {
const ref = useRef<SVGCircleElement>(null);
let color = '';
let emptyColor = 'text-gray-300';
if (useHeatLevel) {
color = 'text-green-500';
if (progress <= 50) {
color = 'text-yellow-500';
}
if (progress <= 10) {
color = 'text-red-500';
}
if (progress === 0) {
emptyColor = 'text-red-600';
}
}
useEffect(() => {
if (ref && ref.current) {
const radius = ref.current?.r.baseVal.value;
const circumference = (radius ?? 0) * 2 * Math.PI;
const offset = circumference - (progress / 100) * circumference;
ref.current.style.strokeDashoffset = `${offset}`;
ref.current.style.strokeDasharray = `${circumference} ${circumference}`;
}
});
return (
<svg className={`${className} ${color}`} viewBox="0 0 24 24">
<circle
className={`${emptyColor} opacity-30`}
stroke="currentColor"
strokeWidth="3"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
<circle
style={{
transition: '0.35s stroke-dashoffset',
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
}}
ref={ref}
stroke="currentColor"
strokeWidth="3"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
</svg>
);
};
export default ProgressCircle;

View File

@@ -1,17 +1,17 @@
import React from 'react';
import useSWR from 'swr';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import Slider from '../Slider';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import RequestCard from '../RequestCard';
import MediaSlider from '../MediaSlider';
import PageTitle from '../Common/PageTitle';
import StudioSlider from './StudioSlider';
import NetworkSlider from './NetworkSlider';
import MediaSlider from '../MediaSlider';
import RequestCard from '../RequestCard';
import Slider from '../Slider';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import MovieGenreSlider from './MovieGenreSlider';
import NetworkSlider from './NetworkSlider';
import StudioSlider from './StudioSlider';
import TvGenreSlider from './TvGenreSlider';
const messages = defineMessages({
@@ -30,14 +30,16 @@ const Discover: React.FC = () => {
const intl = useIntl();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
const {
data: requests,
error: requestError,
} = useSWR<RequestResultsResponse>(
'/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0'
'/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
);
return (

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
movieRequestLimit: '{quotaLimit} movies per {quotaDays} days',
tvRequestLimit: '{quotaLimit} seasons per {quotaDays} days',
unlimited: 'Unlimited',
});
interface QuotaSelectorProps {
mediaType: 'movie' | 'tv';
defaultDays?: number;
defaultLimit?: number;
dayOverride?: number;
limitOverride?: number;
dayFieldName: string;
limitFieldName: string;
isDisabled?: boolean;
onChange: (fieldName: string, value: number) => void;
}
const QuotaSelector: React.FC<QuotaSelectorProps> = ({
mediaType,
dayFieldName,
limitFieldName,
defaultDays = 7,
defaultLimit = 0,
dayOverride,
limitOverride,
isDisabled = false,
onChange,
}) => {
const initialDays = defaultDays ?? 7;
const initialLimit = defaultLimit ?? 0;
const [quotaDays, setQuotaDays] = useState(initialDays);
const [quotaLimit, setQuotaLimit] = useState(initialLimit);
const intl = useIntl();
useEffect(() => {
onChange(dayFieldName, quotaDays);
}, [dayFieldName, onChange, quotaDays]);
useEffect(() => {
onChange(limitFieldName, quotaLimit);
}, [limitFieldName, onChange, quotaLimit]);
return (
<div className={`${isDisabled ? 'opacity-50' : ''}`}>
{intl.formatMessage(
mediaType === 'movie'
? messages.movieRequestLimit
: messages.tvRequestLimit,
{
quotaLimit: (
<select
className="inline short"
value={limitOverride ?? quotaLimit}
onChange={(e) => setQuotaLimit(Number(e.target.value))}
disabled={isDisabled}
>
<option value="0">
{intl.formatMessage(messages.unlimited)}
</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
quotaDays: (
<select
className="inline short"
value={dayOverride ?? quotaDays}
onChange={(e) => setQuotaDays(Number(e.target.value))}
disabled={isDisabled}
>
<option value="1">1</option>
<option value="7">7</option>
<option value="14">14</option>
<option value="30">30</option>
<option value="60">60</option>
<option value="90">90</option>
</select>
),
}
)}
</div>
);
};
export default React.memo(QuotaSelector);

View File

@@ -8,6 +8,7 @@ import {
MediaStatus,
} from '../../../server/constants/media';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import { Permission } from '../../../server/lib/permissions';
import { MovieDetails } from '../../../server/models/Movie';
import DownloadIcon from '../../assets/download.svg';
@@ -16,9 +17,10 @@ import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Modal from '../Common/Modal';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import QuotaDisplay from './QuotaDisplay';
const messages = defineMessages({
requestadmin: 'Your request will be immediately approved.',
requestadmin: 'Your request will be approved automatically.',
cancelrequest:
'This will remove your request. Are you sure you want to continue?',
requestSuccess: '<strong>{title}</strong> requested successfully!',
@@ -37,7 +39,6 @@ const messages = defineMessages({
request4kfrom: 'There is currently a pending 4K request from {username}.',
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request edited.',
autoapproval: 'Automatic Approval',
requesterror: 'Something went wrong while submitting the request.',
});
@@ -69,6 +70,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
});
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);
useEffect(() => {
if (onUpdating) {
@@ -260,13 +264,22 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
);
}
const hasAutoApprove = hasPermission(
[
Permission.MANAGE_REQUESTS,
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE,
],
{ type: 'or' }
);
return (
<Modal
loading={!data && !error}
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating}
okDisabled={isUpdating || quota?.movie.restricted}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.title }
@@ -279,20 +292,24 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />}
>
{(hasPermission(Permission.MANAGE_REQUESTS) ||
hasPermission(
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE
) ||
hasPermission(
is4k
? Permission.AUTO_APPROVE_4K_MOVIE
: Permission.AUTO_APPROVE_MOVIE
)) && (
<p className="mt-6">
<Alert title={intl.formatMessage(messages.autoapproval)} type="info">
{intl.formatMessage(messages.requestadmin)}
</Alert>
</p>
{hasAutoApprove && !quota?.movie.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.movie.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="movie"
quota={quota?.movie}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (

View File

@@ -0,0 +1,173 @@
import Link from 'next/link';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { QuotaStatus } from '../../../../server/interfaces/api/userInterfaces';
import ProgressCircle from '../../Common/ProgressCircle';
const messages = defineMessages({
requestsremaining:
'{remaining, plural, =0 {No} other {<strong>#</strong>}} {type} {remaining, plural, one {requests} other {requests}} remaining',
movielimit: '{limit, plural, one {movie} other {movies}}',
seasonlimit: '{limit, plural, one {season} other {seasons}}',
allowedRequests:
'You are allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.',
allowedRequestsUser:
'This user is allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.',
quotaLink:
'You can view a summary of your request limits on your <ProfileLink>profile page</ProfileLink>.',
quotaLinkUser:
"You can view a summary of this user's request limits on their <ProfileLink>profile page</ProfileLink>.",
movie: 'movie',
season: 'season',
notenoughseasonrequests: 'Not enough season requests remaining',
requiredquota:
'You need to have at least <strong>{seasons}</strong> {seasons, plural, one {season request} other {season requests}} remaining in order to submit a request for this series.',
});
interface QuotaDisplayProps {
quota?: QuotaStatus;
mediaType: 'movie' | 'tv';
userOverride?: number | null;
remaining?: number;
overLimit?: number;
}
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
quota,
mediaType,
userOverride,
remaining,
overLimit,
}) => {
const intl = useIntl();
const [showDetails, setShowDetails] = useState(false);
return (
<div
className="flex flex-col p-4 my-4 bg-gray-800 rounded-md"
onClick={() => setShowDetails((s) => !s)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setShowDetails((s) => !s);
}
}}
role="button"
tabIndex={0}
>
<div className="flex items-center">
<ProgressCircle
className="w-8 h-8"
progress={Math.max(
0,
Math.round(
((remaining ?? quota?.remaining ?? 0) / (quota?.limit ?? 1)) * 100
)
)}
useHeatLevel
/>
<div
className={`flex items-end ${
Math.max(0, remaining ?? quota?.remaining ?? 0) === 0 ||
quota?.restricted
? 'text-red-500'
: ''
}`}
>
<div className="ml-2 text-lg">
{overLimit !== undefined
? intl.formatMessage(messages.notenoughseasonrequests)
: intl.formatMessage(messages.requestsremaining, {
remaining: Math.max(0, remaining ?? quota?.remaining ?? 0),
type: intl.formatMessage(
mediaType === 'movie' ? messages.movie : messages.season
),
strong: function strong(msg) {
return <span className="font-bold">{msg}</span>;
},
})}
</div>
</div>
<div className="flex justify-end flex-1">
{showDetails ? (
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
)}
</div>
</div>
{showDetails && (
<div className="mt-4">
{overLimit !== undefined && (
<div className="mb-2">
{intl.formatMessage(messages.requiredquota, {
seasons: overLimit,
strong: function strong(msg) {
return <span className="font-bold">{msg}</span>;
},
})}
</div>
)}
<div>
{intl.formatMessage(
userOverride
? messages.allowedRequestsUser
: messages.allowedRequests,
{
limit: quota?.limit,
days: quota?.days,
type: intl.formatMessage(
mediaType === 'movie'
? messages.movielimit
: messages.seasonlimit,
{ limit: quota?.limit }
),
strong: function strong(msg) {
return <span className="font-bold">{msg}</span>;
},
}
)}
</div>
<div className="mt-2">
{intl.formatMessage(
userOverride ? messages.quotaLinkUser : messages.quotaLink,
{
ProfileLink: function ProfileLink(msg) {
return (
<Link
href={userOverride ? `/user/${userOverride}` : '/profile'}
>
<a className="text-white hover:underline">{msg}</a>
</Link>
);
},
}
)}
</div>
</div>
)}
</div>
);
};
export default QuotaDisplay;

View File

@@ -1,28 +1,30 @@
import React, { useState } from 'react';
import Modal from '../Common/Modal';
import { useUser } from '../../hooks/useUser';
import { Permission } from '../../../server/lib/permissions';
import { defineMessages, useIntl } from 'react-intl';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import useSWR from 'swr';
import { useToasts } from 'react-toast-notifications';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
import axios from 'axios';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
import {
MediaStatus,
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import { TvDetails } from '../../../server/models/Tv';
import Badge from '../Common/Badge';
import globalMessages from '../../i18n/globalMessages';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import SeasonRequest from '../../../server/entity/SeasonRequest';
import Alert from '../Common/Alert';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import SearchByNameModal from './SearchByNameModal';
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import { Permission } from '../../../server/lib/permissions';
import { TvDetails } from '../../../server/models/Tv';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Badge from '../Common/Badge';
import Modal from '../Common/Modal';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import QuotaDisplay from './QuotaDisplay';
import SearchByNameModal from './SearchByNameModal';
const messages = defineMessages({
requestadmin: 'Your request will be immediately approved.',
requestadmin: 'Your request will be approved automatically.',
cancelrequest:
'This will remove your request. Are you sure you want to continue?',
requestSuccess: '<strong>{title}</strong> requested successfully!',
@@ -79,13 +81,19 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
editRequest ? editingSeasons : []
);
const intl = useIntl();
const { hasPermission } = useUser();
const { user, hasPermission } = useUser();
const [searchModal, setSearchModal] = useState<{
show: boolean;
}>({
show: true,
});
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
const { data: quota } = useSWR<QuotaResponse>(
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);
const currentlyRemaining =
(quota?.tv.remaining ?? 0) - selectedSeasons.length;
const updateRequest = async () => {
if (!editRequest) {
@@ -246,6 +254,15 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
return;
}
// If there are no more remaining requests available, block toggle
if (
quota?.tv.limit &&
currentlyRemaining <= 0 &&
!isSelectedSeason(seasonNumber)
) {
return;
}
if (selectedSeasons.includes(seasonNumber)) {
setSelectedSeasons((seasons) =>
seasons.filter((sn) => sn !== seasonNumber)
@@ -255,20 +272,25 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
}
};
const unrequestedSeasons = getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
);
const toggleAllSeasons = (): void => {
// If the user has a quota and not enough requests for all seasons, block toggleAllSeasons
if (
quota?.tv.limit &&
(quota?.tv.remaining ?? 0) < unrequestedSeasons.length
) {
return;
}
if (
data &&
selectedSeasons.length >= 0 &&
selectedSeasons.length <
getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
).length
selectedSeasons.length < unrequestedSeasons.length
) {
setSelectedSeasons(
getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
)
);
setSelectedSeasons(unrequestedSeasons);
} else {
setSelectedSeasons([]);
}
@@ -352,6 +374,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
okDisabled={
editRequest
? false
: !settings.currentSettings.partialRequestsEnabled &&
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
? true
: getAllRequestedSeasons().length >= getAllSeasons().length ||
(settings.currentSettings.partialRequestsEnabled &&
selectedSeasons.length === 0)
@@ -393,17 +418,43 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
],
{ type: 'or' }
) &&
!(
quota?.tv.limit &&
!settings.currentSettings.partialRequestsEnabled &&
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
) &&
getAllRequestedSeasons().length < getAllSeasons().length &&
!editRequest && (
<p className="mt-6">
<Alert
title={intl.formatMessage(messages.autoapproval)}
title={intl.formatMessage(messages.requestadmin)}
type="info"
>
{intl.formatMessage(messages.requestadmin)}
</Alert>
/>
</p>
)}
{(quota?.movie.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="tv"
quota={quota?.tv}
remaining={
!settings.currentSettings.partialRequestsEnabled &&
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
? 0
: currentlyRemaining
}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
overLimit={
!settings.currentSettings.partialRequestsEnabled &&
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
? unrequestedSeasons.length
: undefined
}
/>
)}
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
@@ -427,7 +478,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
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 items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${
quota?.tv.remaining &&
quota.tv.limit &&
quota.tv.remaining < unrequestedSeasons.length
? 'opacity-50'
: ''
}`}
>
<span
aria-hidden="true"
@@ -494,6 +551,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
}}
className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
mediaSeason ||
(quota?.tv.limit &&
currentlyRemaining <= 0 &&
!isSelectedSeason(season.seasonNumber)) ||
(!!seasonRequest &&
!editingSeasons.includes(season.seasonNumber))
? 'opacity-50'

View File

@@ -1,15 +1,16 @@
import React from 'react';
import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import type { MainSettings } from '../../../../server/lib/settings';
import { Form, Formik, Field } from 'formik';
import axios from 'axios';
import Button from '../../Common/Button';
import { Field, Form, Formik } from 'formik';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import PermissionEdit from '../../PermissionEdit';
import PageTitle from '../../Common/PageTitle';
import useSWR from 'swr';
import type { MainSettings } from '../../../../server/lib/settings';
import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle';
import PermissionEdit from '../../PermissionEdit';
import QuotaSelector from '../../QuotaSelector';
const messages = defineMessages({
users: 'Users',
@@ -19,8 +20,12 @@ const messages = defineMessages({
saving: 'Saving…',
toastSettingsSuccess: 'User settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
localLogin: 'Enable Local User Sign-In',
defaultPermissions: 'Default User Permissions',
localLogin: 'Enable Local Sign-In',
movieRequestLimitLabel: 'Global Movie Request Limit',
movieRequestLimit: '{quotaLimit} movies per {quotaDays} days',
tvRequestLimitLabel: 'Global Series Request Limit',
tvRequestLimit: '{quotaLimit} seasons per {quotaDays} days',
defaultPermissions: 'Default Permissions',
});
const SettingsUsers: React.FC = () => {
@@ -52,6 +57,10 @@ const SettingsUsers: React.FC = () => {
<Formik
initialValues={{
localLogin: data?.localLogin,
movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0,
movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7,
tvQuotaLimit: data?.defaultQuotas.tv.quotaLimit ?? 0,
tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7,
defaultPermissions: data?.defaultPermissions ?? 0,
}}
enableReinitialize
@@ -59,6 +68,16 @@ const SettingsUsers: React.FC = () => {
try {
await axios.post('/api/v1/settings/main', {
localLogin: values.localLogin,
defaultQuotas: {
movie: {
quotaLimit: values.movieQuotaLimit,
quotaDays: values.movieQuotaDays,
},
tv: {
quotaLimit: values.tvQuotaLimit,
quotaDays: values.tvQuotaDays,
},
},
defaultPermissions: values.defaultPermissions,
});
@@ -94,6 +113,36 @@ const SettingsUsers: React.FC = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="applicationTitle" className="text-label">
{intl.formatMessage(messages.movieRequestLimitLabel)}
</label>
<div className="form-input">
<QuotaSelector
onChange={setFieldValue}
dayFieldName="movieQuotaDays"
limitFieldName="movieQuotaLimit"
mediaType="movie"
defaultDays={values.movieQuotaDays}
defaultLimit={values.movieQuotaLimit}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="applicationTitle" className="text-label">
{intl.formatMessage(messages.tvRequestLimitLabel)}
</label>
<div className="form-input">
<QuotaSelector
onChange={setFieldValue}
dayFieldName="tvQuotaDays"
limitFieldName="tvQuotaLimit"
mediaType="tv"
defaultDays={values.tvQuotaDays}
defaultLimit={values.tvQuotaLimit}
/>
</div>
</div>
<div
role="group"
aria-labelledby="group-label"

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

View File

@@ -145,6 +145,9 @@
"components.PlexLoginButton.loading": "Loading…",
"components.PlexLoginButton.signingin": "Signing in…",
"components.PlexLoginButton.signinwithplex": "Sign In",
"components.QuotaSelector.movieRequestLimit": "{quotaLimit} movies per {quotaDays} days",
"components.QuotaSelector.tvRequestLimit": "{quotaLimit} seasons per {quotaDays} days",
"components.QuotaSelector.unlimited": "Unlimited",
"components.RegionSelector.regionDefault": "All Regions",
"components.RegionSelector.regionServerDefault": "Default ({region})",
"components.RequestBlock.profilechanged": "Quality Profile",
@@ -202,6 +205,17 @@
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",
"components.RequestModal.AdvancedRequester.requestas": "Request As",
"components.RequestModal.AdvancedRequester.rootfolder": "Root Folder",
"components.RequestModal.QuotaDisplay.allowedRequests": "You are allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.",
"components.RequestModal.QuotaDisplay.allowedRequestsUser": "This user is allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.",
"components.RequestModal.QuotaDisplay.movie": "movie",
"components.RequestModal.QuotaDisplay.movielimit": "{limit, plural, one {movie} other {movies}}",
"components.RequestModal.QuotaDisplay.notenoughseasonrequests": "Not enough season requests remaining",
"components.RequestModal.QuotaDisplay.quotaLink": "You can view a summary of your request limits on your <ProfileLink>profile page</ProfileLink>.",
"components.RequestModal.QuotaDisplay.quotaLinkUser": "You can view a summary of this user's request limits on their <ProfileLink>profile page</ProfileLink>.",
"components.RequestModal.QuotaDisplay.requestsremaining": "{remaining, plural, =0 {No} other {<strong>#</strong>}} {type} {remaining, plural, one {requests} other {requests}} remaining",
"components.RequestModal.QuotaDisplay.requiredquota": "You need to have at least <strong>{seasons}</strong> {seasons, plural, one {season request} other {season requests}} remaining in order to submit a request for this series.",
"components.RequestModal.QuotaDisplay.season": "season",
"components.RequestModal.QuotaDisplay.seasonlimit": "{limit, plural, one {season} other {seasons}}",
"components.RequestModal.SearchByNameModal.next": "Next",
"components.RequestModal.SearchByNameModal.nosummary": "No summary for this title was found.",
"components.RequestModal.SearchByNameModal.notvdbid": "Manual Match Required",
@@ -225,7 +239,7 @@
"components.RequestModal.request4ktitle": "Request {title} in 4K",
"components.RequestModal.requestCancel": "Request for <strong>{title}</strong> canceled.",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested successfully!",
"components.RequestModal.requestadmin": "Your request will be immediately approved.",
"components.RequestModal.requestadmin": "Your request will be approved automatically.",
"components.RequestModal.requestall": "Request All Seasons",
"components.RequestModal.requestcancelled": "Request canceled.",
"components.RequestModal.requestedited": "Request edited.",
@@ -469,12 +483,16 @@
"components.Settings.SettingsLogs.showingresults": "Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results",
"components.Settings.SettingsLogs.time": "Timestamp",
"components.Settings.SettingsLogs.viewDetails": "View Details",
"components.Settings.SettingsUsers.defaultPermissions": "Default User Permissions",
"components.Settings.SettingsUsers.localLogin": "Enable Local User Sign-In",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
"components.Settings.SettingsUsers.movieRequestLimit": "{quotaLimit} movies per {quotaDays} days",
"components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit",
"components.Settings.SettingsUsers.save": "Save Changes",
"components.Settings.SettingsUsers.saving": "Saving…",
"components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.SettingsUsers.toastSettingsSuccess": "User settings saved successfully!",
"components.Settings.SettingsUsers.tvRequestLimit": "{quotaLimit} seasons per {quotaDays} days",
"components.Settings.SettingsUsers.tvRequestLimitLabel": "Global Series Request Limit",
"components.Settings.SettingsUsers.userSettings": "User Settings",
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
@@ -735,16 +753,17 @@
"components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters",
"components.UserProfile.ProfileHeader.joindate": "Joined {joindate}",
"components.UserProfile.ProfileHeader.profile": "View Profile",
"components.UserProfile.ProfileHeader.requests": "{requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.UserProfile.ProfileHeader.settings": "Edit Settings",
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
"components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name",
"components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Enable Override",
"components.UserProfile.UserSettings.UserGeneralSettings.general": "General",
"components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings",
"components.UserProfile.UserSettings.UserGeneralSettings.languageServerDefault": "Default ({language})",
"components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Local User",
"components.UserProfile.UserSettings.UserGeneralSettings.movierequestlimit": "Movie Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.originalLanguageDefault": "All Languages",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Discover Language",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filter content by original language",
@@ -755,6 +774,7 @@
"components.UserProfile.UserSettings.UserGeneralSettings.role": "Role",
"components.UserProfile.UserSettings.UserGeneralSettings.save": "Save Changes",
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…",
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
@@ -813,8 +833,15 @@
"components.UserProfile.UserSettings.menuPermissions": "Permissions",
"components.UserProfile.UserSettings.unauthorized": "Unauthorized",
"components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.",
"components.UserProfile.limit": "{remaining} of {limit}",
"components.UserProfile.movierequests": "Movie Requests",
"components.UserProfile.norequests": "No Requests",
"components.UserProfile.pastdays": "{type} (past {days} days)",
"components.UserProfile.recentrequests": "Recent Requests",
"components.UserProfile.requestsperdays": "{limit} remaining",
"components.UserProfile.seriesrequest": "Series Requests",
"components.UserProfile.totalrequests": "Total Requests",
"components.UserProfile.unlimited": "Unlimited",
"i18n.advanced": "Advanced",
"i18n.approve": "Approve",
"i18n.approved": "Approved",

View File

@@ -210,7 +210,7 @@ img.avatar-sm {
}
.form-input {
@apply text-white sm:col-span-2;
@apply text-sm text-white sm:col-span-2;
}
.form-input-field {