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>pull/1275/head
parent
a65e3d5bb6
commit
6c75c88228
@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserQuotaFields1616576677254 implements MigrationInterface {
|
||||
name = 'AddUserQuotaFields1616576677254';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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);
|
@ -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;
|
Loading…
Reference in new issue