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 type: array
items: items:
$ref: '#/components/schemas/MediaRequest' $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: /user/{userId}/settings/main:
get: get:
summary: Get general settings for a user summary: Get general settings for a user

View File

@@ -10,6 +10,7 @@ import {
getRepository, getRepository,
OneToMany, OneToMany,
AfterRemove, AfterRemove,
RelationCount,
} from 'typeorm'; } from 'typeorm';
import { User } from './User'; import { User } from './User';
import Media from './Media'; import Media from './Media';
@@ -60,6 +61,9 @@ export class MediaRequest {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
public type: MediaType; public type: MediaType;
@RelationCount((request: MediaRequest) => request.seasons)
public seasonCount: number;
@OneToMany(() => SeasonRequest, (season) => season.request, { @OneToMany(() => SeasonRequest, (season) => season.request, {
eager: true, eager: true,
cascade: 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 bcrypt from 'bcrypt';
import path from 'path'; 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 { 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 { 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'; import { UserSettings } from './UserSettings';
@Entity() @Entity()
@@ -80,6 +86,18 @@ export class User {
@OneToMany(() => MediaRequest, (request) => request.requestedBy) @OneToMany(() => MediaRequest, (request) => request.requestedBy)
public requests: MediaRequest[]; 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, { @OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true, cascade: true,
eager: true, eager: true,
@@ -199,4 +217,105 @@ export class User {
public setDisplayName(): void { public setDisplayName(): void {
this.displayName = this.username || this.plexUsername; 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 { MediaRequest } from '../../entity/MediaRequest';
import type { User } from '../../entity/User';
import { PaginatedResponse } from './common'; import { PaginatedResponse } from './common';
export interface UserResultsResponse extends PaginatedResponse { export interface UserResultsResponse extends PaginatedResponse {
@@ -9,3 +9,16 @@ export interface UserResultsResponse extends PaginatedResponse {
export interface UserRequestsResponse extends PaginatedResponse { export interface UserRequestsResponse extends PaginatedResponse {
results: MediaRequest[]; 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; username?: string;
region?: string; region?: string;
originalLanguage?: string; originalLanguage?: string;
movieQuotaLimit?: number;
movieQuotaDays?: number;
tvQuotaLimit?: number;
tvQuotaDays?: number;
globalMovieQuotaDays?: number;
globalMovieQuotaLimit?: number;
globalTvQuotaLimit?: number;
globalTvQuotaDays?: number;
} }
export interface UserSettingsNotificationsResponse { export interface UserSettingsNotificationsResponse {

View File

@@ -1,6 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Permission } from './permissions'; import { Permission } from './permissions';
@@ -61,6 +61,11 @@ export interface SonarrSettings extends DVRSettings {
enableSeasonFolders: boolean; enableSeasonFolders: boolean;
} }
interface Quota {
quotaLimit?: number;
quotaDays?: number;
}
export interface MainSettings { export interface MainSettings {
apiKey: string; apiKey: string;
applicationTitle: string; applicationTitle: string;
@@ -68,6 +73,10 @@ export interface MainSettings {
csrfProtection: boolean; csrfProtection: boolean;
cacheImages: boolean; cacheImages: boolean;
defaultPermissions: number; defaultPermissions: number;
defaultQuotas: {
movie: Quota;
tv: Quota;
};
hideAvailable: boolean; hideAvailable: boolean;
localLogin: boolean; localLogin: boolean;
region: string; region: string;
@@ -199,6 +208,10 @@ class Settings {
csrfProtection: false, csrfProtection: false,
cacheImages: false, cacheImages: false,
defaultPermissions: Permission.REQUEST, defaultPermissions: Permission.REQUEST,
defaultQuotas: {
movie: {},
tv: {},
},
hideAvailable: false, hideAvailable: false,
localLogin: true, localLogin: true,
region: '', 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 { Router } from 'express';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { MediaRequest } from '../entity/MediaRequest';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import Media from '../entity/Media'; import Media from '../entity/Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; import { MediaRequest } from '../entity/MediaRequest';
import SeasonRequest from '../entity/SeasonRequest'; import SeasonRequest from '../entity/SeasonRequest';
import logger from '../logger';
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
import { User } from '../entity/User'; 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(); 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 = const tmdbMedia =
req.body.mediaType === 'movie' req.body.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: req.body.mediaId }) ? await tmdb.getMovie({ movieId: req.body.mediaId })
: await tmdb.getTvShow({ tvId: 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({ const existing = await requestRepository.findOne({
where: { where: {
media: { media: {
@@ -247,7 +268,7 @@ requestRoutes.post(
await requestRepository.save(request); await requestRepository.save(request);
return res.status(201).json(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[]; const requestedSeasons = req.body.seasons as number[];
let existingSeasons: 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.serverId = req.body.serverId;
request.profileId = req.body.profileId; request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder; request.rootFolder = req.body.rootFolder;
request.requestedBy = requestUser as User; request.requestedBy = requestUser as User;
requestRepository.save(request); requestRepository.save(request);
} else if (req.body.mediaType === 'tv') { } else if (req.body.mediaType === MediaType.TV) {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
request.serverId = req.body.serverId; request.serverId = req.body.serverId;
request.profileId = req.body.profileId; request.profileId = req.body.profileId;

View File

@@ -1,16 +1,19 @@
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { getRepository, Not } from 'typeorm'; import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../../api/plextv'; import PlexTvAPI from '../../api/plextv';
import { UserType } from '../../constants/user';
import { MediaRequest } from '../../entity/MediaRequest'; import { MediaRequest } from '../../entity/MediaRequest';
import { User } from '../../entity/User'; import { User } from '../../entity/User';
import {
QuotaResponse,
UserRequestsResponse,
UserResultsResponse,
} from '../../interfaces/api/userInterfaces';
import { hasPermission, Permission } from '../../lib/permissions'; import { hasPermission, Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings'; import { getSettings } from '../../lib/settings';
import logger from '../../logger'; import logger from '../../logger';
import gravatarUrl from 'gravatar-url';
import { UserType } from '../../constants/user';
import { isAuthenticated } from '../../middleware/auth'; import { isAuthenticated } from '../../middleware/auth';
import { UserResultsResponse } from '../../interfaces/api/userInterfaces';
import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
import userSettingsRoutes from './usersettings'; import userSettingsRoutes from './usersettings';
const router = Router(); 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; export default router;

View File

@@ -2,13 +2,13 @@ import { Router } from 'express';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { canMakePermissionsChange } from '.'; import { canMakePermissionsChange } from '.';
import { User } from '../../entity/User'; import { User } from '../../entity/User';
import { getSettings } from '../../lib/settings';
import { UserSettings } from '../../entity/UserSettings'; import { UserSettings } from '../../entity/UserSettings';
import { import {
UserSettingsGeneralResponse, UserSettingsGeneralResponse,
UserSettingsNotificationsResponse, UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces'; } from '../../interfaces/api/userSettingsInterfaces';
import { Permission } from '../../lib/permissions'; import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger'; import logger from '../../logger';
import { isAuthenticated } from '../../middleware/auth'; import { isAuthenticated } from '../../middleware/auth';
@@ -35,6 +35,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
'/main', '/main',
isOwnProfileOrAdmin(), isOwnProfileOrAdmin(),
async (req, res, next) => { async (req, res, next) => {
const {
main: { defaultQuotas },
} = getSettings();
const userRepository = getRepository(User); const userRepository = getRepository(User);
try { try {
@@ -50,6 +53,14 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
username: user.username, username: user.username,
region: user.settings?.region, region: user.settings?.region,
originalLanguage: user.settings?.originalLanguage, 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) { } catch (e) {
next({ status: 500, message: e.message }); next({ status: 500, message: e.message });
@@ -82,6 +93,18 @@ userSettingsRoutes.post<
} }
user.username = req.body.username; 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) { if (!user.settings) {
user.settings = new UserSettings({ user.settings = new UserSettings({
user: req.user, user: req.user,

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
interface AlertProps { interface AlertProps {
title: string; title?: React.ReactNode;
type?: 'warning' | 'info' | 'error'; type?: 'warning' | 'info' | 'error';
} }
@@ -77,14 +77,20 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
} }
return ( 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">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div> <div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3"> <div className="ml-3">
<div className={`text-sm font-medium ${design.titleColor}`}> {title && (
{title} <div className={`text-sm font-medium ${design.titleColor}`}>
</div> {title}
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div> </div>
)}
{children && (
<div className={`mt-2 first:mt-0 text-sm ${design.textColor}`}>
{children}
</div>
)}
</div> </div>
</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 Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces'; import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import RequestCard from '../RequestCard';
import MediaSlider from '../MediaSlider';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import StudioSlider from './StudioSlider'; import MediaSlider from '../MediaSlider';
import NetworkSlider from './NetworkSlider'; import RequestCard from '../RequestCard';
import Slider from '../Slider';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import MovieGenreSlider from './MovieGenreSlider'; import MovieGenreSlider from './MovieGenreSlider';
import NetworkSlider from './NetworkSlider';
import StudioSlider from './StudioSlider';
import TvGenreSlider from './TvGenreSlider'; import TvGenreSlider from './TvGenreSlider';
const messages = defineMessages({ const messages = defineMessages({
@@ -30,14 +30,16 @@ const Discover: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>( 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 { const {
data: requests, data: requests,
error: requestError, error: requestError,
} = useSWR<RequestResultsResponse>( } = 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 ( 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, MediaStatus,
} from '../../../server/constants/media'; } from '../../../server/constants/media';
import { MediaRequest } from '../../../server/entity/MediaRequest'; import { MediaRequest } from '../../../server/entity/MediaRequest';
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import { Permission } from '../../../server/lib/permissions'; import { Permission } from '../../../server/lib/permissions';
import { MovieDetails } from '../../../server/models/Movie'; import { MovieDetails } from '../../../server/models/Movie';
import DownloadIcon from '../../assets/download.svg'; import DownloadIcon from '../../assets/download.svg';
@@ -16,9 +17,10 @@ import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert'; import Alert from '../Common/Alert';
import Modal from '../Common/Modal'; import Modal from '../Common/Modal';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import QuotaDisplay from './QuotaDisplay';
const messages = defineMessages({ const messages = defineMessages({
requestadmin: 'Your request will be immediately approved.', requestadmin: 'Your request will be approved automatically.',
cancelrequest: cancelrequest:
'This will remove your request. Are you sure you want to continue?', 'This will remove your request. Are you sure you want to continue?',
requestSuccess: '<strong>{title}</strong> requested successfully!', requestSuccess: '<strong>{title}</strong> requested successfully!',
@@ -37,7 +39,6 @@ const messages = defineMessages({
request4kfrom: 'There is currently a pending 4K request from {username}.', request4kfrom: 'There is currently a pending 4K request from {username}.',
errorediting: 'Something went wrong while editing the request.', errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request edited.', requestedited: 'Request edited.',
autoapproval: 'Automatic Approval',
requesterror: 'Something went wrong while submitting the request.', requesterror: 'Something went wrong while submitting the request.',
}); });
@@ -69,6 +70,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
}); });
const intl = useIntl(); const intl = useIntl();
const { user, hasPermission } = useUser(); const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
);
useEffect(() => { useEffect(() => {
if (onUpdating) { 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 ( return (
<Modal <Modal
loading={!data && !error} loading={(!data && !error) || !quota}
backgroundClickable backgroundClickable
onCancel={onCancel} onCancel={onCancel}
onOk={sendRequest} onOk={sendRequest}
okDisabled={isUpdating} okDisabled={isUpdating || quota?.movie.restricted}
title={intl.formatMessage( title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle, is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.title } { title: data?.title }
@@ -279,20 +292,24 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
okButtonType={'primary'} okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />} iconSvg={<DownloadIcon className="w-6 h-6" />}
> >
{(hasPermission(Permission.MANAGE_REQUESTS) || {hasAutoApprove && !quota?.movie.restricted && (
hasPermission( <div className="mt-6">
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE <Alert
) || title={intl.formatMessage(messages.requestadmin)}
hasPermission( type="info"
is4k />
? Permission.AUTO_APPROVE_4K_MOVIE </div>
: Permission.AUTO_APPROVE_MOVIE )}
)) && ( {(quota?.movie.limit ?? 0) > 0 && (
<p className="mt-6"> <QuotaDisplay
<Alert title={intl.formatMessage(messages.autoapproval)} type="info"> mediaType="movie"
{intl.formatMessage(messages.requestadmin)} quota={quota?.movie}
</Alert> userOverride={
</p> requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)} )}
{(hasPermission(Permission.REQUEST_ADVANCED) || {(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && ( 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 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 { import {
MediaStatus,
MediaRequestStatus, MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media'; } from '../../../server/constants/media';
import { TvDetails } from '../../../server/models/Tv'; import { MediaRequest } from '../../../server/entity/MediaRequest';
import Badge from '../Common/Badge';
import globalMessages from '../../i18n/globalMessages';
import SeasonRequest from '../../../server/entity/SeasonRequest'; import SeasonRequest from '../../../server/entity/SeasonRequest';
import Alert from '../Common/Alert'; import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; import { Permission } from '../../../server/lib/permissions';
import SearchByNameModal from './SearchByNameModal'; import { TvDetails } from '../../../server/models/Tv';
import useSettings from '../../hooks/useSettings'; 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({ const messages = defineMessages({
requestadmin: 'Your request will be immediately approved.', requestadmin: 'Your request will be approved automatically.',
cancelrequest: cancelrequest:
'This will remove your request. Are you sure you want to continue?', 'This will remove your request. Are you sure you want to continue?',
requestSuccess: '<strong>{title}</strong> requested successfully!', requestSuccess: '<strong>{title}</strong> requested successfully!',
@@ -79,13 +81,19 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
editRequest ? editingSeasons : [] editRequest ? editingSeasons : []
); );
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { user, hasPermission } = useUser();
const [searchModal, setSearchModal] = useState<{ const [searchModal, setSearchModal] = useState<{
show: boolean; show: boolean;
}>({ }>({
show: true, show: true,
}); });
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined); 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 () => { const updateRequest = async () => {
if (!editRequest) { if (!editRequest) {
@@ -246,6 +254,15 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
return; return;
} }
// If there are no more remaining requests available, block toggle
if (
quota?.tv.limit &&
currentlyRemaining <= 0 &&
!isSelectedSeason(seasonNumber)
) {
return;
}
if (selectedSeasons.includes(seasonNumber)) { if (selectedSeasons.includes(seasonNumber)) {
setSelectedSeasons((seasons) => setSelectedSeasons((seasons) =>
seasons.filter((sn) => sn !== seasonNumber) 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 => { 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 ( if (
data && data &&
selectedSeasons.length >= 0 && selectedSeasons.length >= 0 &&
selectedSeasons.length < selectedSeasons.length < unrequestedSeasons.length
getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
).length
) { ) {
setSelectedSeasons( setSelectedSeasons(unrequestedSeasons);
getAllSeasons().filter(
(season) => !getAllRequestedSeasons().includes(season)
)
);
} else { } else {
setSelectedSeasons([]); setSelectedSeasons([]);
} }
@@ -352,6 +374,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
okDisabled={ okDisabled={
editRequest editRequest
? false ? false
: !settings.currentSettings.partialRequestsEnabled &&
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
? true
: getAllRequestedSeasons().length >= getAllSeasons().length || : getAllRequestedSeasons().length >= getAllSeasons().length ||
(settings.currentSettings.partialRequestsEnabled && (settings.currentSettings.partialRequestsEnabled &&
selectedSeasons.length === 0) selectedSeasons.length === 0)
@@ -393,17 +418,43 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
], ],
{ type: 'or' } { type: 'or' }
) && ) &&
!(
quota?.tv.limit &&
!settings.currentSettings.partialRequestsEnabled &&
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
) &&
getAllRequestedSeasons().length < getAllSeasons().length && getAllRequestedSeasons().length < getAllSeasons().length &&
!editRequest && ( !editRequest && (
<p className="mt-6"> <p className="mt-6">
<Alert <Alert
title={intl.formatMessage(messages.autoapproval)} title={intl.formatMessage(messages.requestadmin)}
type="info" type="info"
> />
{intl.formatMessage(messages.requestadmin)}
</Alert>
</p> </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="flex flex-col">
<div className="-mx-4 sm:mx-0"> <div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle"> <div className="inline-block min-w-full py-2 align-middle">
@@ -427,7 +478,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleAllSeasons(); 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 <span
aria-hidden="true" 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 ${ className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
mediaSeason || mediaSeason ||
(quota?.tv.limit &&
currentlyRemaining <= 0 &&
!isSelectedSeason(season.seasonNumber)) ||
(!!seasonRequest && (!!seasonRequest &&
!editingSeasons.includes(season.seasonNumber)) !editingSeasons.includes(season.seasonNumber))
? 'opacity-50' ? '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 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 { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import PermissionEdit from '../../PermissionEdit'; import useSWR from 'swr';
import PageTitle from '../../Common/PageTitle'; import type { MainSettings } from '../../../../server/lib/settings';
import globalMessages from '../../../i18n/globalMessages'; 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({ const messages = defineMessages({
users: 'Users', users: 'Users',
@@ -19,8 +20,12 @@ const messages = defineMessages({
saving: 'Saving…', saving: 'Saving…',
toastSettingsSuccess: 'User settings saved successfully!', toastSettingsSuccess: 'User settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.', toastSettingsFailure: 'Something went wrong while saving settings.',
localLogin: 'Enable Local User Sign-In', localLogin: 'Enable Local Sign-In',
defaultPermissions: 'Default User Permissions', 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 = () => { const SettingsUsers: React.FC = () => {
@@ -52,6 +57,10 @@ const SettingsUsers: React.FC = () => {
<Formik <Formik
initialValues={{ initialValues={{
localLogin: data?.localLogin, 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, defaultPermissions: data?.defaultPermissions ?? 0,
}} }}
enableReinitialize enableReinitialize
@@ -59,6 +68,16 @@ const SettingsUsers: React.FC = () => {
try { try {
await axios.post('/api/v1/settings/main', { await axios.post('/api/v1/settings/main', {
localLogin: values.localLogin, localLogin: values.localLogin,
defaultQuotas: {
movie: {
quotaLimit: values.movieQuotaLimit,
quotaDays: values.movieQuotaDays,
},
tv: {
quotaLimit: values.tvQuotaLimit,
quotaDays: values.tvQuotaDays,
},
},
defaultPermissions: values.defaultPermissions, defaultPermissions: values.defaultPermissions,
}); });
@@ -94,6 +113,36 @@ const SettingsUsers: React.FC = () => {
/> />
</div> </div>
</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 <div
role="group" role="group"
aria-labelledby="group-label" aria-labelledby="group-label"

View File

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

View File

@@ -1,20 +1,22 @@
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import { Language } from '../../../../../server/lib/settings'; import { Language } from '../../../../../server/lib/settings';
import useSettings from '../../../../hooks/useSettings'; 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 Error from '../../../../pages/_error';
import Badge from '../../../Common/Badge'; import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button'; import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner'; import LoadingSpinner from '../../../Common/LoadingSpinner';
import RegionSelector from '../../../RegionSelector';
import globalMessages from '../../../../i18n/globalMessages';
import PageTitle from '../../../Common/PageTitle'; import PageTitle from '../../../Common/PageTitle';
import QuotaSelector from '../../../QuotaSelector';
import RegionSelector from '../../../RegionSelector';
const messages = defineMessages({ const messages = defineMessages({
general: 'General', general: 'General',
@@ -37,21 +39,25 @@ const messages = defineMessages({
originallanguageTip: 'Filter content by original language', originallanguageTip: 'Filter content by original language',
originalLanguageDefault: 'All Languages', originalLanguageDefault: 'All Languages',
languageServerDefault: 'Default ({language})', languageServerDefault: 'Default ({language})',
movierequestlimit: 'Movie Request Limit',
seriesrequestlimit: 'Series Request Limit',
enableOverride: 'Enable Override',
}); });
const UserGeneralSettings: React.FC = () => { const UserGeneralSettings: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false);
const router = useRouter(); const router = useRouter();
const { user, hasPermission, mutate } = useUser({ const { user, hasPermission, mutate } = useUser({
id: Number(router.query.userId), id: Number(router.query.userId),
}); });
const { hasPermission: currentHasPermission } = useUser();
const { currentSettings } = useSettings(); const { currentSettings } = useSettings();
const { data, error, revalidate } = useSWR<{ const { data, error, revalidate } = useSWR<UserSettingsGeneralResponse>(
username?: string; user ? `/api/v1/user/${user?.id}/settings/main` : null
region?: string; );
originalLanguage?: string;
}>(user ? `/api/v1/user/${user?.id}/settings/main` : null);
const { data: languages, error: languagesError } = useSWR<Language[]>( const { data: languages, error: languagesError } = useSWR<Language[]>(
'/api/v1/languages' '/api/v1/languages'
@@ -111,6 +117,10 @@ const UserGeneralSettings: React.FC = () => {
displayName: data?.username, displayName: data?.username,
region: data?.region, region: data?.region,
originalLanguage: data?.originalLanguage, originalLanguage: data?.originalLanguage,
movieQuotaLimit: data?.movieQuotaLimit,
movieQuotaDays: data?.movieQuotaDays,
tvQuotaLimit: data?.tvQuotaLimit,
tvQuotaDays: data?.tvQuotaDays,
}} }}
enableReinitialize enableReinitialize
onSubmit={async (values) => { onSubmit={async (values) => {
@@ -119,6 +129,12 @@ const UserGeneralSettings: React.FC = () => {
username: values.displayName, username: values.displayName,
region: values.region, region: values.region,
originalLanguage: values.originalLanguage, 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), { addToast(intl.formatMessage(messages.toastSettingsSuccess), {
@@ -252,6 +268,91 @@ const UserGeneralSettings: React.FC = () => {
</div> </div>
</div> </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="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm"> <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 { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { hasPermission, Permission } from '../../../../server/lib/permissions';
import useSettings from '../../../hooks/useSettings';
import { useUser } from '../../../hooks/useUser'; import { useUser } from '../../../hooks/useUser';
import { Permission, hasPermission } from '../../../../server/lib/permissions'; import globalMessages from '../../../i18n/globalMessages';
import Error from '../../../pages/_error'; import Error from '../../../pages/_error';
import Alert from '../../Common/Alert';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle'; import PageTitle from '../../Common/PageTitle';
import ProfileHeader from '../ProfileHeader'; import ProfileHeader from '../ProfileHeader';
import useSettings from '../../../hooks/useSettings';
import Alert from '../../Common/Alert';
import globalMessages from '../../../i18n/globalMessages';
const messages = defineMessages({ const messages = defineMessages({
menuGeneralSettings: 'General', menuGeneralSettings: 'General',

View File

@@ -1,22 +1,33 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
import { useUser } from '../../hooks/useUser'; import {
import Error from '../../pages/_error'; QuotaResponse,
import LoadingSpinner from '../Common/LoadingSpinner'; UserRequestsResponse,
import { UserRequestsResponse } from '../../../server/interfaces/api/userInterfaces'; } from '../../../server/interfaces/api/userInterfaces';
import Slider from '../Slider';
import RequestCard from '../RequestCard';
import { MovieDetails } from '../../../server/models/Movie'; import { MovieDetails } from '../../../server/models/Movie';
import { TvDetails } from '../../../server/models/Tv'; import { TvDetails } from '../../../server/models/Tv';
import { Permission, useUser } from '../../hooks/useUser';
import Error from '../../pages/_error';
import ImageFader from '../Common/ImageFader'; import ImageFader from '../Common/ImageFader';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import ProgressCircle from '../Common/ProgressCircle';
import RequestCard from '../RequestCard';
import Slider from '../Slider';
import ProfileHeader from './ProfileHeader'; import ProfileHeader from './ProfileHeader';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
recentrequests: 'Recent Requests', recentrequests: 'Recent Requests',
norequests: 'No 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; type MediaTitle = MovieDetails | TvDetails;
@@ -27,6 +38,7 @@ const UserProfile: React.FC = () => {
const { user, error } = useUser({ const { user, error } = useUser({
id: Number(router.query.userId), id: Number(router.query.userId),
}); });
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const [availableTitles, setAvailableTitles] = useState< const [availableTitles, setAvailableTitles] = useState<
Record<number, MediaTitle> Record<number, MediaTitle>
>({}); >({});
@@ -34,6 +46,9 @@ const UserProfile: React.FC = () => {
const { data: requests, error: requestError } = useSWR<UserRequestsResponse>( const { data: requests, error: requestError } = useSWR<UserRequestsResponse>(
user ? `/api/v1/user/${user?.id}/requests?take=10&skip=0` : null 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( const updateAvailableTitles = useCallback(
(requestId: number, mediaTitle: MediaTitle) => { (requestId: number, mediaTitle: MediaTitle) => {
@@ -76,6 +91,140 @@ const UserProfile: React.FC = () => {
</div> </div>
)} )}
<ProfileHeader user={user} /> <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="relative z-40 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0"> <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"> <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.loading": "Loading…",
"components.PlexLoginButton.signingin": "Signing in…", "components.PlexLoginButton.signingin": "Signing in…",
"components.PlexLoginButton.signinwithplex": "Sign 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.regionDefault": "All Regions",
"components.RegionSelector.regionServerDefault": "Default ({region})", "components.RegionSelector.regionServerDefault": "Default ({region})",
"components.RequestBlock.profilechanged": "Quality Profile", "components.RequestBlock.profilechanged": "Quality Profile",
@@ -202,6 +205,17 @@
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",
"components.RequestModal.AdvancedRequester.requestas": "Request As", "components.RequestModal.AdvancedRequester.requestas": "Request As",
"components.RequestModal.AdvancedRequester.rootfolder": "Root Folder", "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.next": "Next",
"components.RequestModal.SearchByNameModal.nosummary": "No summary for this title was found.", "components.RequestModal.SearchByNameModal.nosummary": "No summary for this title was found.",
"components.RequestModal.SearchByNameModal.notvdbid": "Manual Match Required", "components.RequestModal.SearchByNameModal.notvdbid": "Manual Match Required",
@@ -225,7 +239,7 @@
"components.RequestModal.request4ktitle": "Request {title} in 4K", "components.RequestModal.request4ktitle": "Request {title} in 4K",
"components.RequestModal.requestCancel": "Request for <strong>{title}</strong> canceled.", "components.RequestModal.requestCancel": "Request for <strong>{title}</strong> canceled.",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested successfully!", "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.requestall": "Request All Seasons",
"components.RequestModal.requestcancelled": "Request canceled.", "components.RequestModal.requestcancelled": "Request canceled.",
"components.RequestModal.requestedited": "Request edited.", "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.showingresults": "Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results",
"components.Settings.SettingsLogs.time": "Timestamp", "components.Settings.SettingsLogs.time": "Timestamp",
"components.Settings.SettingsLogs.viewDetails": "View Details", "components.Settings.SettingsLogs.viewDetails": "View Details",
"components.Settings.SettingsUsers.defaultPermissions": "Default User Permissions", "components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.localLogin": "Enable Local User Sign-In", "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.save": "Save Changes",
"components.Settings.SettingsUsers.saving": "Saving…", "components.Settings.SettingsUsers.saving": "Saving…",
"components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.", "components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.SettingsUsers.toastSettingsSuccess": "User settings saved successfully!", "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.userSettings": "User Settings",
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.", "components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users", "components.Settings.SettingsUsers.users": "Users",
@@ -735,16 +753,17 @@
"components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", "components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters",
"components.UserProfile.ProfileHeader.joindate": "Joined {joindate}", "components.UserProfile.ProfileHeader.joindate": "Joined {joindate}",
"components.UserProfile.ProfileHeader.profile": "View Profile", "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.settings": "Edit Settings",
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}", "components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type", "components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin", "components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
"components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name", "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name",
"components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Enable Override",
"components.UserProfile.UserSettings.UserGeneralSettings.general": "General", "components.UserProfile.UserSettings.UserGeneralSettings.general": "General",
"components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings", "components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings",
"components.UserProfile.UserSettings.UserGeneralSettings.languageServerDefault": "Default ({language})", "components.UserProfile.UserSettings.UserGeneralSettings.languageServerDefault": "Default ({language})",
"components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Local User", "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.originalLanguageDefault": "All Languages",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Discover Language", "components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Discover Language",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguageTip": "Filter content by original 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.role": "Role",
"components.UserProfile.UserSettings.UserGeneralSettings.save": "Save Changes", "components.UserProfile.UserSettings.UserGeneralSettings.save": "Save Changes",
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…", "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.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User", "components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
@@ -813,8 +833,15 @@
"components.UserProfile.UserSettings.menuPermissions": "Permissions", "components.UserProfile.UserSettings.menuPermissions": "Permissions",
"components.UserProfile.UserSettings.unauthorized": "Unauthorized", "components.UserProfile.UserSettings.unauthorized": "Unauthorized",
"components.UserProfile.UserSettings.unauthorizedDescription": "You do not have permission to modify this user's settings.", "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.norequests": "No Requests",
"components.UserProfile.pastdays": "{type} (past {days} days)",
"components.UserProfile.recentrequests": "Recent Requests", "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.advanced": "Advanced",
"i18n.approve": "Approve", "i18n.approve": "Approve",
"i18n.approved": "Approved", "i18n.approved": "Approved",

View File

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