diff --git a/overseerr-api.yml b/overseerr-api.yml index 5d5bc035d..9fe9975f0 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -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 diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 606ccb4c2..6e5c11358 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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, diff --git a/server/entity/User.ts b/server/entity/User.ts index 50ede81df..db5fa9504 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -1,28 +1,34 @@ +import bcrypt from 'bcrypt'; +import path from 'path'; +import { default as generatePassword } from 'secure-random-password'; import { - Entity, - PrimaryGeneratedColumn, + AfterLoad, Column, CreateDateColumn, - UpdateDateColumn, + Entity, + getRepository, + MoreThan, + Not, OneToMany, - RelationCount, - AfterLoad, 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 { - Permission, hasPermission, + Permission, 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 { v4 as uuid } from 'uuid'; +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 { + 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, + }, + }; + } } diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index 259455dc9..facacd54c 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -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; +} diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 91653991f..e6d0302fe 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -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 { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 706ed19eb..5809600f8 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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: '', diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/1616576677254-AddUserQuotaFields.ts new file mode 100644 index 000000000..44947baba --- /dev/null +++ b/server/migration/1616576677254-AddUserQuotaFields.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserQuotaFields1616576677254 implements MigrationInterface { + name = 'AddUserQuotaFields1616576677254'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } +} diff --git a/server/routes/request.ts b/server/routes/request.ts index 085975218..ead984f64 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -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; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index d29567a61..2ddc700fc 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -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; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 02ff2c72c..693c228e5 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -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, diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 1b4bd8542..b29513cc6 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -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 = ({ title, children, type }) => { } return ( -
+
{design.svg}
-
- {title} -
-
{children}
+ {title && ( +
+ {title} +
+ )} + {children && ( +
+ {children} +
+ )}
diff --git a/src/components/Common/ProgressCircle/index.tsx b/src/components/Common/ProgressCircle/index.tsx new file mode 100644 index 000000000..64ca49c17 --- /dev/null +++ b/src/components/Common/ProgressCircle/index.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useRef } from 'react'; + +interface ProgressCircleProps { + className?: string; + progress?: number; + useHeatLevel?: boolean; +} + +const ProgressCircle: React.FC = ({ + className, + progress = 0, + useHeatLevel, +}) => { + const ref = useRef(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 ( + + + + + ); +}; + +export default ProgressCircle; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 790305e84..74e416f6a 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -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( - '/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( - '/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 ( diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx new file mode 100644 index 000000000..45b610dcc --- /dev/null +++ b/src/components/QuotaSelector/index.tsx @@ -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 = ({ + 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 ( +
+ {intl.formatMessage( + mediaType === 'movie' + ? messages.movieRequestLimit + : messages.tvRequestLimit, + { + quotaLimit: ( + + ), + quotaDays: ( + + ), + } + )} +
+ ); +}; + +export default React.memo(QuotaSelector); diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index df9e397f9..ce93e8fda 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -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: '{title} 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 = ({ }); const intl = useIntl(); const { user, hasPermission } = useUser(); + const { data: quota } = useSWR( + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null + ); useEffect(() => { if (onUpdating) { @@ -260,13 +264,22 @@ const MovieRequestModal: React.FC = ({ ); } + 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 ( = ({ okButtonType={'primary'} iconSvg={} > - {(hasPermission(Permission.MANAGE_REQUESTS) || - hasPermission( - is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE - ) || - hasPermission( - is4k - ? Permission.AUTO_APPROVE_4K_MOVIE - : Permission.AUTO_APPROVE_MOVIE - )) && ( -

- - {intl.formatMessage(messages.requestadmin)} - -

+ {hasAutoApprove && !quota?.movie.restricted && ( +
+ +
+ )} + {(quota?.movie.limit ?? 0) > 0 && ( + )} {(hasPermission(Permission.REQUEST_ADVANCED) || hasPermission(Permission.MANAGE_REQUESTS)) && ( diff --git a/src/components/RequestModal/QuotaDisplay/index.tsx b/src/components/RequestModal/QuotaDisplay/index.tsx new file mode 100644 index 000000000..041a7454a --- /dev/null +++ b/src/components/RequestModal/QuotaDisplay/index.tsx @@ -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 {#}} {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 {limit} {type} every {days} days.', + allowedRequestsUser: + 'This user is allowed to request {limit} {type} every {days} days.', + quotaLink: + 'You can view a summary of your request limits on your profile page.', + quotaLinkUser: + "You can view a summary of this user's request limits on their profile page.", + movie: 'movie', + season: 'season', + notenoughseasonrequests: 'Not enough season requests remaining', + requiredquota: + 'You need to have at least {seasons} {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 = ({ + quota, + mediaType, + userOverride, + remaining, + overLimit, +}) => { + const intl = useIntl(); + const [showDetails, setShowDetails] = useState(false); + return ( +
setShowDetails((s) => !s)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setShowDetails((s) => !s); + } + }} + role="button" + tabIndex={0} + > +
+ +
+
+ {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 {msg}; + }, + })} +
+
+
+ {showDetails ? ( + + + + ) : ( + + + + )} +
+
+ {showDetails && ( +
+ {overLimit !== undefined && ( +
+ {intl.formatMessage(messages.requiredquota, { + seasons: overLimit, + strong: function strong(msg) { + return {msg}; + }, + })} +
+ )} +
+ {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 {msg}; + }, + } + )} +
+
+ {intl.formatMessage( + userOverride ? messages.quotaLinkUser : messages.quotaLink, + { + ProfileLink: function ProfileLink(msg) { + return ( + + {msg} + + ); + }, + } + )} +
+
+ )} +
+ ); +}; + +export default QuotaDisplay; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 305c95fc9..575f6194a 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -1,28 +1,30 @@ +import axios from 'axios'; 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 useSWR from 'swr'; import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants'; -import axios from 'axios'; import { - MediaStatus, MediaRequestStatus, + MediaStatus, } from '../../../server/constants/media'; +import { MediaRequest } from '../../../server/entity/MediaRequest'; +import SeasonRequest from '../../../server/entity/SeasonRequest'; +import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces'; +import { Permission } from '../../../server/lib/permissions'; import { TvDetails } from '../../../server/models/Tv'; -import Badge from '../Common/Badge'; +import useSettings from '../../hooks/useSettings'; +import { useUser } from '../../hooks/useUser'; import globalMessages from '../../i18n/globalMessages'; -import SeasonRequest from '../../../server/entity/SeasonRequest'; 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'; -import useSettings from '../../hooks/useSettings'; 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: '{title} requested successfully!', @@ -79,13 +81,19 @@ const TvRequestModal: React.FC = ({ editRequest ? editingSeasons : [] ); const intl = useIntl(); - const { hasPermission } = useUser(); + const { user, hasPermission } = useUser(); const [searchModal, setSearchModal] = useState<{ show: boolean; }>({ show: true, }); const [tvdbId, setTvdbId] = useState(undefined); + const { data: quota } = useSWR( + 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 = ({ 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 = ({ } }; + 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 = ({ 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 = ({ ], { type: 'or' } ) && + !( + quota?.tv.limit && + !settings.currentSettings.partialRequestsEnabled && + unrequestedSeasons.length > (quota?.tv.limit ?? 0) + ) && getAllRequestedSeasons().length < getAllSeasons().length && !editRequest && (

- {intl.formatMessage(messages.requestadmin)} - + />

)} + {(quota?.movie.limit ?? 0) > 0 && ( + (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 + } + /> + )}
@@ -427,7 +478,13 @@ const TvRequestModal: React.FC = ({ 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' + : '' + }`} >
+
+ +
+ +
+
+
+ +
+ +
+
= ({ day: 'numeric', }), }), - intl.formatMessage(messages.requests, { - requestCount: user.requestCount, - }), ]; if (hasPermission(Permission.MANAGE_REQUESTS)) { diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index a6c8d3169..9c39806ff 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -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( + user ? `/api/v1/user/${user?.id}/settings/main` : null + ); const { data: languages, error: languagesError } = useSWR( '/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 = () => {
+ {currentHasPermission(Permission.MANAGE_USERS) && + !hasPermission(Permission.MANAGE_USERS) && ( + <> +
+ +
+
+
+ setMovieQuotaEnabled((s) => !s)} + /> + + {intl.formatMessage(messages.enableOverride)} + +
+ +
+
+
+
+ +
+
+
+ setTvQuotaEnabled((s) => !s)} + /> + + {intl.formatMessage(messages.enableOverride)} + +
+ +
+
+
+ + )}
diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx index 0ef749088..d2a1f2401 100644 --- a/src/components/UserProfile/UserSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/index.tsx @@ -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', diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx index 3ca2e1922..a828193b8 100644 --- a/src/components/UserProfile/index.tsx +++ b/src/components/UserProfile/index.tsx @@ -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 >({}); @@ -34,6 +46,9 @@ const UserProfile: React.FC = () => { const { data: requests, error: requestError } = useSWR( user ? `/api/v1/user/${user?.id}/requests?take=10&skip=0` : null ); + const { data: quota } = useSWR( + user ? `/api/v1/user/${user.id}/quota` : null + ); const updateAvailableTitles = useCallback( (requestId: number, mediaTitle: MediaTitle) => { @@ -76,6 +91,140 @@ const UserProfile: React.FC = () => {
)} + {quota && + (user.id === currentUser?.id || + currentHasPermission(Permission.MANAGE_USERS)) && ( +
+
+
+
+ {intl.formatMessage(messages.totalrequests)} +
+
+ {intl.formatNumber(user.requestCount)} +
+
+ +
+
+ {quota.tv.limit + ? intl.formatMessage(messages.pastdays, { + type: intl.formatMessage(messages.movierequests), + days: quota?.movie.days, + }) + : intl.formatMessage(messages.movierequests)} +
+
+ {quota.movie.limit ? ( + <> + +
+ {intl.formatMessage(messages.requestsperdays, { + limit: ( + + {intl.formatMessage(messages.limit, { + remaining: quota.movie.remaining, + limit: quota.movie.limit, + })} + + ), + })} +
+ + ) : ( + + {intl.formatMessage(messages.unlimited)} + + )} +
+
+ +
+
+ {quota.tv.limit + ? intl.formatMessage(messages.pastdays, { + type: intl.formatMessage(messages.seriesrequest), + days: quota?.tv.days, + }) + : intl.formatMessage(messages.seriesrequest)} +
+
+ {quota.tv.limit ? ( + <> + +
+ {intl.formatMessage(messages.requestsperdays, { + limit: ( + + {intl.formatMessage(messages.limit, { + remaining: quota.tv.remaining, + limit: quota.tv.limit, + })} + + ), + })} +
+ + ) : ( + + {intl.formatMessage(messages.unlimited)} + + )} +
+
+
+
+ )}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 01ec36666..0b9e53263 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -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 {limit} {type} every {days} days.", + "components.RequestModal.QuotaDisplay.allowedRequestsUser": "This user is allowed to request {limit} {type} every {days} 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 profile page.", + "components.RequestModal.QuotaDisplay.quotaLinkUser": "You can view a summary of this user's request limits on their profile page.", + "components.RequestModal.QuotaDisplay.requestsremaining": "{remaining, plural, =0 {No} other {#}} {type} {remaining, plural, one {requests} other {requests}} remaining", + "components.RequestModal.QuotaDisplay.requiredquota": "You need to have at least {seasons} {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 {title} canceled.", "components.RequestModal.requestSuccess": "{title} 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 {from} to {to} of {total} 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", diff --git a/src/styles/globals.css b/src/styles/globals.css index 4e72d0c41..4bf04fa77 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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 {