mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05: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,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>
|
||||
|
||||
74
src/components/Common/ProgressCircle/index.tsx
Normal file
74
src/components/Common/ProgressCircle/index.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
94
src/components/QuotaSelector/index.tsx
Normal file
94
src/components/QuotaSelector/index.tsx
Normal 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);
|
||||
@@ -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)) && (
|
||||
|
||||
173
src/components/RequestModal/QuotaDisplay/index.tsx
Normal file
173
src/components/RequestModal/QuotaDisplay/index.tsx
Normal 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;
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user