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

@@ -3033,6 +3033,63 @@ paths:
type: array
items:
$ref: '#/components/schemas/MediaRequest'
/user/{userId}/quota:
get:
summary: Get quotas for a specific user
description: |
Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users.
tags:
- users
parameters:
- in: path
name: userId
required: true
schema:
type: number
responses:
'200':
description: User quota details in JSON
content:
application/json:
schema:
type: object
properties:
movie:
type: object
properties:
days:
type: number
example: 7
limit:
type: number
example: 10
used:
type: number
example: 6
remaining:
type: number
example: 4
restricted:
type: boolean
example: false
tv:
type: object
properties:
days:
type: number
example: 7
limit:
type: number
example: 10
used:
type: number
example: 6
remaining:
type: number
example: 4
restricted:
type: boolean
example: false
/user/{userId}/settings/main:
get:
summary: Get general settings for a user

View File

@@ -10,6 +10,7 @@ import {
getRepository,
OneToMany,
AfterRemove,
RelationCount,
} from 'typeorm';
import { User } from './User';
import Media from './Media';
@@ -60,6 +61,9 @@ export class MediaRequest {
@Column({ type: 'varchar' })
public type: MediaType;
@RelationCount((request: MediaRequest) => request.seasons)
public seasonCount: number;
@OneToMany(() => SeasonRequest, (season) => season.request, {
eager: true,
cascade: true,

View File

@@ -1,28 +1,34 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
RelationCount,
AfterLoad,
OneToOne,
} from 'typeorm';
import {
Permission,
hasPermission,
PermissionCheckOptions,
} from '../lib/permissions';
import { MediaRequest } from './MediaRequest';
import bcrypt from 'bcrypt';
import path from 'path';
import PreparedEmail from '../lib/email';
import logger from '../logger';
import { getSettings } from '../lib/settings';
import { default as generatePassword } from 'secure-random-password';
import { UserType } from '../constants/user';
import {
AfterLoad,
Column,
CreateDateColumn,
Entity,
getRepository,
MoreThan,
Not,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import { v4 as uuid } from 'uuid';
import { MediaRequestStatus, MediaType } from '../constants/media';
import { UserType } from '../constants/user';
import { QuotaResponse } from '../interfaces/api/userInterfaces';
import PreparedEmail from '../lib/email';
import {
hasPermission,
Permission,
PermissionCheckOptions,
} from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { MediaRequest } from './MediaRequest';
import SeasonRequest from './SeasonRequest';
import { UserSettings } from './UserSettings';
@Entity()
@@ -80,6 +86,18 @@ export class User {
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
public requests: MediaRequest[];
@Column({ nullable: true })
public movieQuotaLimit?: number;
@Column({ nullable: true })
public movieQuotaDays?: number;
@Column({ nullable: true })
public tvQuotaLimit?: number;
@Column({ nullable: true })
public tvQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@@ -199,4 +217,105 @@ export class User {
public setDisplayName(): void {
this.displayName = this.username || this.plexUsername;
}
public async getQuota(): Promise<QuotaResponse> {
const {
main: { defaultQuotas },
} = getSettings();
const requestRepository = getRepository(MediaRequest);
const canBypass = this.hasPermission([Permission.MANAGE_USERS], {
type: 'or',
});
const movieQuotaLimit = !canBypass
? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit
: 0;
const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays;
// Count movie requests made during quota period
const movieDate = new Date();
if (movieQuotaDays) {
movieDate.setDate(movieDate.getDate() - movieQuotaDays);
} else {
movieDate.setDate(0);
}
// YYYY-MM-DD format
const movieQuotaStartDate = movieDate.toJSON().split('T')[0];
const movieQuotaUsed = movieQuotaLimit
? await requestRepository.count({
where: {
requestedBy: this,
createdAt: MoreThan(movieQuotaStartDate),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
},
})
: 0;
const tvQuotaLimit = !canBypass
? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit
: 0;
const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays;
// Count tv season requests made during quota period
const tvDate = new Date();
if (tvQuotaDays) {
tvDate.setDate(tvDate.getDate() - tvQuotaDays);
} else {
tvDate.setDate(0);
}
// YYYY-MM-DD format
const tvQuotaStartDate = tvDate.toJSON().split('T')[0];
const tvQuotaUsed = tvQuotaLimit
? (
await requestRepository
.createQueryBuilder('request')
.leftJoin('request.seasons', 'seasons')
.leftJoin('request.requestedBy', 'requestedBy')
.where('request.type = :requestType', {
requestType: MediaType.TV,
})
.andWhere('requestedBy.id = :userId', {
userId: this.id,
})
.andWhere('request.createdAt > :date', {
date: tvQuotaStartDate,
})
.andWhere('request.status != :declinedStatus', {
declinedStatus: MediaRequestStatus.DECLINED,
})
.addSelect((subQuery) => {
return subQuery
.select('COUNT(season.id)', 'seasonCount')
.from(SeasonRequest, 'season')
.leftJoin('season.request', 'parentRequest')
.where('parentRequest.id = request.id');
}, 'seasonCount')
.getMany()
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0;
return {
movie: {
days: movieQuotaDays,
limit: movieQuotaLimit,
used: movieQuotaUsed,
remaining: movieQuotaLimit
? movieQuotaLimit - movieQuotaUsed
: undefined,
restricted:
movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0
? true
: false,
},
tv: {
days: tvQuotaDays,
limit: tvQuotaLimit,
used: tvQuotaUsed,
remaining: tvQuotaLimit ? tvQuotaLimit - tvQuotaUsed : undefined,
restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
},
};
}
}

View File

@@ -1,5 +1,5 @@
import type { User } from '../../entity/User';
import { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User';
import { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse {
@@ -9,3 +9,16 @@ export interface UserResultsResponse extends PaginatedResponse {
export interface UserRequestsResponse extends PaginatedResponse {
results: MediaRequest[];
}
export interface QuotaStatus {
days?: number;
limit?: number;
used: number;
remaining?: number;
restricted: boolean;
}
export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
}

View File

@@ -2,6 +2,14 @@ export interface UserSettingsGeneralResponse {
username?: string;
region?: string;
originalLanguage?: string;
movieQuotaLimit?: number;
movieQuotaDays?: number;
tvQuotaLimit?: number;
tvQuotaDays?: number;
globalMovieQuotaDays?: number;
globalMovieQuotaLimit?: number;
globalTvQuotaLimit?: number;
globalTvQuotaDays?: number;
}
export interface UserSettingsNotificationsResponse {

View File

@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { merge } from 'lodash';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { Permission } from './permissions';
@@ -61,6 +61,11 @@ export interface SonarrSettings extends DVRSettings {
enableSeasonFolders: boolean;
}
interface Quota {
quotaLimit?: number;
quotaDays?: number;
}
export interface MainSettings {
apiKey: string;
applicationTitle: string;
@@ -68,6 +73,10 @@ export interface MainSettings {
csrfProtection: boolean;
cacheImages: boolean;
defaultPermissions: number;
defaultQuotas: {
movie: Quota;
tv: Quota;
};
hideAvailable: boolean;
localLogin: boolean;
region: string;
@@ -199,6 +208,10 @@ class Settings {
csrfProtection: false,
cacheImages: false,
defaultPermissions: Permission.REQUEST,
defaultQuotas: {
movie: {},
tv: {},
},
hideAvailable: false,
localLogin: true,
region: '',

View File

@@ -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"`);
}
}

View File

@@ -1,15 +1,15 @@
import { Router } from 'express';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import { getRepository } from 'typeorm';
import { MediaRequest } from '../entity/MediaRequest';
import TheMovieDb from '../api/themoviedb';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { MediaRequest } from '../entity/MediaRequest';
import SeasonRequest from '../entity/SeasonRequest';
import logger from '../logger';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { User } from '../entity/User';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
const requestRoutes = Router();
@@ -154,8 +154,29 @@ requestRoutes.post(
});
}
if (!requestUser) {
return next({
status: 500,
message: 'User missing from request context.',
});
}
const quotas = await requestUser.getQuota();
if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
return next({
status: 403,
message: 'Movie Quota Exceeded',
});
} else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) {
return next({
status: 403,
message: 'Series Quota Exceeded',
});
}
const tmdbMedia =
req.body.mediaType === 'movie'
req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.mediaId })
: await tmdb.getTvShow({ tvId: req.body.mediaId });
@@ -182,7 +203,7 @@ requestRoutes.post(
}
}
if (req.body.mediaType === 'movie') {
if (req.body.mediaType === MediaType.MOVIE) {
const existing = await requestRepository.findOne({
where: {
media: {
@@ -247,7 +268,7 @@ requestRoutes.post(
await requestRepository.save(request);
return res.status(201).json(request);
} else if (req.body.mediaType === 'tv') {
} else if (req.body.mediaType === MediaType.TV) {
const requestedSeasons = req.body.seasons as number[];
let existingSeasons: number[] = [];
@@ -458,14 +479,14 @@ requestRoutes.put<{ requestId: string }>(
});
}
if (req.body.mediaType === 'movie') {
if (req.body.mediaType === MediaType.MOVIE) {
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.requestedBy = requestUser as User;
requestRepository.save(request);
} else if (req.body.mediaType === 'tv') {
} else if (req.body.mediaType === MediaType.TV) {
const mediaRepository = getRepository(Media);
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;

View File

@@ -1,16 +1,19 @@
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv';
import { UserType } from '../../constants/user';
import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User';
import {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import gravatarUrl from 'gravatar-url';
import { UserType } from '../../constants/user';
import { isAuthenticated } from '../../middleware/auth';
import { UserResultsResponse } from '../../interfaces/api/userInterfaces';
import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -380,4 +383,36 @@ router.post(
}
);
router.get<{ id: string }, QuotaResponse>(
'/:id/quota',
async (req, res, next) => {
try {
const userRepository = getRepository(User);
if (
Number(req.params.id) !== req.user?.id &&
!req.user?.hasPermission(
[Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS],
{ type: 'and' }
)
) {
return next({
status: 403,
message: 'You do not have permission to access this endpoint.',
});
}
const user = await userRepository.findOneOrFail({
where: { id: Number(req.params.id) },
});
const quotas = await user.getQuota();
return res.status(200).json(quotas);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
export default router;

View File

@@ -2,13 +2,13 @@ import { Router } from 'express';
import { getRepository } from 'typeorm';
import { canMakePermissionsChange } from '.';
import { User } from '../../entity/User';
import { getSettings } from '../../lib/settings';
import { UserSettings } from '../../entity/UserSettings';
import {
UserSettingsGeneralResponse,
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth';
@@ -35,6 +35,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
'/main',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const {
main: { defaultQuotas },
} = getSettings();
const userRepository = getRepository(User);
try {
@@ -50,6 +53,14 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
username: user.username,
region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage,
movieQuotaLimit: user.movieQuotaLimit,
movieQuotaDays: user.movieQuotaDays,
tvQuotaLimit: user.tvQuotaLimit,
tvQuotaDays: user.tvQuotaDays,
globalMovieQuotaDays: defaultQuotas.movie.quotaDays,
globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit,
globalTvQuotaDays: defaultQuotas.tv.quotaDays,
globalTvQuotaLimit: defaultQuotas.tv.quotaLimit,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -82,6 +93,18 @@ userSettingsRoutes.post<
}
user.username = req.body.username;
// Update quota values only if the user has the correct permissions
if (
!user.hasPermission(Permission.MANAGE_USERS) &&
req.user?.id !== user.id
) {
user.movieQuotaDays = req.body.movieQuotaDays;
user.movieQuotaLimit = req.body.movieQuotaLimit;
user.tvQuotaDays = req.body.tvQuotaDays;
user.tvQuotaLimit = req.body.tvQuotaLimit;
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,

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 {