Merge branch 'develop'

pull/570/head
sct 4 years ago
commit c31709681a

@ -189,6 +189,33 @@
"contributions": [
"translation"
]
},
{
"login": "danshilm",
"name": "Danshil Mungur",
"avatar_url": "https://avatars2.githubusercontent.com/u/20923978?v=4",
"profile": "https://github.com/danshilm",
"contributions": [
"code"
]
},
{
"login": "doob187",
"name": "doob187",
"avatar_url": "https://avatars1.githubusercontent.com/u/60312740?v=4",
"profile": "https://github.com/doob187",
"contributions": [
"infra"
]
},
{
"login": "johnpyp",
"name": "johnpyp",
"avatar_url": "https://avatars2.githubusercontent.com/u/20625636?v=4",
"profile": "https://github.com/johnpyp",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

@ -4,7 +4,12 @@ node_modules
.gitconfig
.gitignore
.github
.all-contributorsrc
.editorconfig
.prettierignore
**/README.md
**/.vscode
config/db/db.sqlite3
config/db/logs/overseerr.log
Dockerfil**
**.md

@ -16,7 +16,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-20-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-23-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -123,6 +123,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/Panzer1119"><img src="https://avatars1.githubusercontent.com/u/23016343?v=4" width="100px;" alt=""/><br /><sub><b>Paul Hagedorn</b></sub></a><br /><a href="#translation-Panzer1119" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shagon94"><img src="https://avatars3.githubusercontent.com/u/9140783?v=4" width="100px;" alt=""/><br /><sub><b>Shagon94</b></sub></a><br /><a href="#translation-Shagon94" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/sebstrgg"><img src="https://avatars3.githubusercontent.com/u/27026694?v=4" width="100px;" alt=""/><br /><sub><b>sebstrgg</b></sub></a><br /><a href="#translation-sebstrgg" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/danshilm"><img src="https://avatars2.githubusercontent.com/u/20923978?v=4" width="100px;" alt=""/><br /><sub><b>Danshil Mungur</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=danshilm" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
</tr>
</table>

@ -2,6 +2,7 @@ const devConfig = {
type: 'sqlite',
database: 'config/db/db.sqlite3',
synchronize: true,
migrationsRun: false,
logging: false,
entities: ['server/entity/**/*.ts'],
migrations: ['server/migration/**/*.ts'],
@ -19,7 +20,7 @@ const prodConfig = {
logging: false,
entities: ['dist/entity/**/*.js'],
migrations: ['dist/migration/**/*.js'],
migrationsRun: true,
migrationsRun: false,
subscribers: ['dist/subscriber/**/*.js'],
cli: {
entitiesDir: 'dist/entity',

@ -58,6 +58,9 @@ components:
applicationUrl:
type: string
example: https://os.example.com
defaultPermissions:
type: number
example: 32
PlexLibrary:
type: object
properties:
@ -1488,6 +1491,21 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/NotificationEmailSettings'
/settings/notifications/email/test:
post:
summary: Test the provided email settings
description: Sends a test notification to the email agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NotificationEmailSettings'
responses:
'204':
description: Test notification attempted
/settings/notifications/discord:
get:
summary: Return current discord notification settings
@ -1519,6 +1537,21 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/DiscordSettings'
/settings/notifications/discord/test:
post:
summary: Test the provided discord settings
description: Sends a test notification to the discord agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DiscordSettings'
responses:
'204':
description: Test notification attempted
/settings/about:
get:
summary: Return current about stats
@ -1640,6 +1673,24 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/User'
/user/import-from-plex:
post:
summary: Imports all users from Plex
description: |
Requests users from the Plex Server and creates a new user for each of them
Requires the `MANAGE_USERS` permission.
tags:
- users
responses:
'201':
description: A list of the newly created users
content:
application/json:
schema:
type: array
$ref: '#/components/schemas/User'
/user/{userId}:
get:
summary: Retrieve a user by ID

@ -25,21 +25,21 @@
"cookie-parser": "^1.4.5",
"email-templates": "^8.0.2",
"express": "^4.17.1",
"express-openapi-validator": "^4.8.0",
"express-openapi-validator": "^4.9.4",
"express-session": "^1.17.1",
"formik": "^2.2.5",
"formik": "^2.2.6",
"intl": "^1.2.5",
"lodash": "^4.17.20",
"next": "^10.0.3",
"node-schedule": "^1.3.2",
"nodemailer": "^6.4.16",
"nodemailer": "^6.4.17",
"nookies": "^2.5.0",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-intersection-observer": "^8.31.0",
"react-intl": "^5.10.6",
"react-intl": "^5.10.9",
"react-markdown": "^5.0.3",
"react-spring": "^8.0.27",
"react-toast-notifications": "^2.4.0",
@ -48,7 +48,7 @@
"reflect-metadata": "^0.1.13",
"sqlite3": "^5.0.0",
"swagger-ui-express": "^4.1.5",
"swr": "^0.3.9",
"swr": "^0.3.11",
"typeorm": "^0.2.29",
"uuid": "^8.3.2",
"winston": "^3.3.3",
@ -57,7 +57,7 @@
"yup": "^0.32.8"
},
"devDependencies": {
"@babel/cli": "^7.12.8",
"@babel/cli": "^7.12.10",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@semantic-release/changelog": "^5.0.1",
@ -73,7 +73,7 @@
"@types/express": "^4.17.9",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.165",
"@types/node": "^14.14.11",
"@types/node": "^14.14.14",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.0",
@ -84,24 +84,24 @@
"@types/uuid": "^8.3.0",
"@types/xml2js": "^0.4.7",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.10",
"@typescript-eslint/eslint-plugin": "^4.9.1",
"@typescript-eslint/parser": "^4.9.1",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.10.0",
"@typescript-eslint/parser": "^4.10.0",
"autoprefixer": "^9",
"babel-plugin-react-intl": "^8.2.21",
"babel-plugin-react-intl": "^8.2.22",
"babel-plugin-react-intl-auto": "^3.3.0",
"commitizen": "^4.2.2",
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.15.0",
"eslint-config-prettier": "^7.0.0",
"eslint-plugin-formatjs": "^2.9.10",
"eslint": "^7.16.0",
"eslint-config-prettier": "^7.1.0",
"eslint-plugin-formatjs": "^2.9.11",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.2.0",
"eslint-plugin-prettier": "^3.3.0",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"extract-react-intl-messages": "^4.1.1",
"husky": "^4.3.5",
"husky": "^4.3.6",
"lint-staged": "^10.5.3",
"nodemon": "^2.0.6",
"postcss": "^7",
@ -111,7 +111,7 @@
"semantic-release-docker": "^2.2.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"ts-node": "^9.1.1",
"typescript": "^4.1.2"
"typescript": "^4.1.3"
},
"config": {
"commitizen": {

@ -1 +1,20 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#1e2937","display":"standalone"}
{
"name": "Overseerr",
"short_name": "Overseerr",
"start_url": "./",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#1e2937",
"display": "standalone"
}

@ -56,6 +56,21 @@ interface FriendResponse {
};
}
interface UsersResponse {
MediaContainer: {
User: {
$: {
id: string;
title: string;
username: string;
email: string;
thumb: string;
};
Server: ServerResponse[];
}[];
};
}
class PlexTvAPI {
private authToken: string;
private axios: AxiosInstance;
@ -129,6 +144,18 @@ class PlexTvAPI {
return false;
}
}
public async getUsers(): Promise<UsersResponse> {
const response = await this.axios.get('/api/users', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise(
response.data
)) as UsersResponse;
return parsedXml;
}
}
export default PlexTvAPI;

@ -78,7 +78,7 @@ class RadarrAPI {
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
try {
await this.axios.post<RadarrMovie>(`/movie`, {
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
@ -92,6 +92,19 @@ class RadarrAPI {
searchForMovie: options.searchNow,
},
});
if (response.data.id) {
logger.info('Radarr accepted request', { label: 'Radarr' });
logger.debug('Radarr add details', {
label: 'Radarr',
movie: response.data,
});
} else {
logger.error('Failed to add movie to Radarr', {
label: 'Radarr',
options,
});
}
} catch (e) {
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',

@ -126,7 +126,7 @@ class SonarrAPI {
series.addOptions = {
ignoreEpisodesWithFiles: true,
searchForMissingEpisodes: true,
searchForMissingEpisodes: options.searchNow,
};
const newSeriesResponse = await this.axios.put<SonarrSeries>(
@ -134,6 +134,21 @@ class SonarrAPI {
series
);
if (newSeriesResponse.data.id) {
logger.info('Sonarr accepted request. Updated existing series', {
label: 'Sonarr',
});
logger.debug('Sonarr add details', {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
label: 'Sonarr',
options,
});
}
return newSeriesResponse.data;
}
@ -162,6 +177,19 @@ class SonarrAPI {
} as Partial<SonarrSeries>
);
if (createdSeriesResponse.data.id) {
logger.info('Sonarr accepted request', { label: 'Sonarr' });
logger.debug('Sonarr add details', {
label: 'Sonarr',
movie: createdSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
label: 'Sonarr',
options,
});
}
return createdSeriesResponse.data;
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {

@ -374,7 +374,12 @@ class TheMovieDb {
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to search multi: ${e.message}`);
return {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
}
};

@ -92,6 +92,9 @@ class Media {
@UpdateDateColumn()
public updatedAt: Date;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public lastSeasonChange: Date;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}

@ -29,7 +29,15 @@ const handle = app.getRequestHandler();
app
.prepare()
.then(async () => {
await createConnection();
const dbConnection = await createConnection();
// Run migrations in production
if (process.env.NODE_ENV === 'production') {
await dbConnection.query('PRAGMA foreign_keys=OFF');
await dbConnection.runMigrations();
await dbConnection.query('PRAGMA foreign_keys=ON');
}
// Load Settings
const settings = getSettings().load();

@ -1,7 +1,10 @@
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI, { PlexLibraryItem } from '../../api/plexapi';
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import TheMovieDb, {
TmdbMovieDetails,
TmdbTvDetails,
} from '../../api/themoviedb';
import Media from '../../entity/Media';
import { MediaStatus, MediaType } from '../../constants/media';
import logger from '../../logger';
@ -93,40 +96,58 @@ class JobPlexSync {
this.log(`Saved ${plexitem.title}`);
}
} else {
const matchedid = plexitem.guid.match(/imdb:\/\/(tt[0-9]+)/);
let tmdbMovieId: number | undefined;
let tmdbMovie: TmdbMovieDetails | undefined;
if (matchedid?.[1]) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: matchedid[1],
const imdbMatch = plexitem.guid.match(imdbRegex);
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (imdbMatch) {
tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: imdbMatch[1],
});
tmdbMovieId = tmdbMovie.id;
} else if (tmdbMatch) {
tmdbMovieId = Number(tmdbMatch[1]);
}
const existing = await this.getExisting(tmdbMovie.id);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
await mediaRepository.save(existing);
this.log(
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
'info'
);
} else if (tmdbMovie) {
const newMedia = new Media();
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
newMedia.tmdbId = tmdbMovie.id;
newMedia.status = MediaStatus.AVAILABLE;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`);
if (!tmdbMovieId) {
throw new Error('Unable to find TMDB ID');
}
const existing = await this.getExisting(tmdbMovieId);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
await mediaRepository.save(existing);
this.log(
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
'info'
);
} else {
// If we have a tmdb movie guid but it didn't already exist, only then
// do we request the movie from tmdb (to reduce api requests)
if (!tmdbMovie) {
tmdbMovie = await this.tmdb.getMovie({ movieId: tmdbMovieId });
}
const newMedia = new Media();
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
newMedia.tmdbId = tmdbMovie.id;
newMedia.status = MediaStatus.AVAILABLE;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`);
}
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
plexitem.parentRatingKey ?? plexitem.ratingKey
}`,
'error'
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
@ -169,6 +190,12 @@ class JobPlexSync {
const newSeasons: Season[] = [];
const currentSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
seasons.forEach((season) => {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
@ -219,6 +246,25 @@ class JobPlexSync {
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newSeasonAvailable > currentSeasonAvailable) {
this.log(
`Detected ${
newSeasonAvailable - currentSeasonAvailable
} new season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
media.status = isAllSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE;

@ -1,5 +1,6 @@
import { Notification } from '..';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
subject: string;
@ -9,6 +10,15 @@ export interface NotificationPayload {
extra?: { name: string; value: string }[];
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {
protected settings?: T;
public constructor(settings?: T) {
this.settings = settings;
}
protected abstract getSettings(): T;
}
export interface NotificationAgent {
shouldSend(type: Notification): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;

@ -1,8 +1,8 @@
import axios from 'axios';
import { Notification } from '..';
import logger from '../../../logger';
import { getSettings } from '../../settings';
import type { NotificationAgent, NotificationPayload } from './agent';
import { getSettings, NotificationAgentDiscord } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
DEFAULT = 0,
@ -37,6 +37,11 @@ interface DiscordImageEmbed {
width?: number;
}
interface Field {
name: string;
value: string;
inline?: boolean;
}
interface DiscordRichEmbed {
title?: string;
type?: 'rich'; // Always rich for webhooks
@ -61,11 +66,7 @@ interface DiscordRichEmbed {
icon_url?: string;
proxy_icon_url?: string;
};
fields?: {
name: string;
value: string;
inline?: boolean;
}[];
fields?: Field[];
}
interface DiscordWebhookPayload {
@ -75,26 +76,72 @@ interface DiscordWebhookPayload {
tts: boolean;
}
class DiscordAgent implements NotificationAgent {
class DiscordAgent
extends BaseAgent<NotificationAgentDiscord>
implements NotificationAgent {
protected getSettings(): NotificationAgentDiscord {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.discord;
}
public buildEmbed(
type: Notification,
payload: NotificationPayload
): DiscordRichEmbed {
let color = EmbedColors.DEFAULT;
let status = 'Unknown';
const fields: Field[] = [];
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
status = 'Pending Approval';
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Pending Approval',
inline: true,
}
);
break;
case Notification.MEDIA_APPROVED:
color = EmbedColors.PURPLE;
status = 'Processing Request';
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Processing Request',
inline: true,
}
);
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
status = 'Available';
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: 'Available',
inline: true,
}
);
break;
}
@ -105,16 +152,7 @@ class DiscordAgent implements NotificationAgent {
timestamp: new Date().toISOString(),
author: { name: 'Overseerr' },
fields: [
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
inline: true,
},
{
name: 'Status',
value: status,
inline: true,
},
...fields,
// If we have extra data, map it to fields for discord notifications
...(payload.extra ?? []).map((extra) => ({
name: extra.name,
@ -130,12 +168,7 @@ class DiscordAgent implements NotificationAgent {
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
const settings = getSettings();
if (
settings.notifications.agents.discord?.enabled &&
settings.notifications.agents.discord?.options?.webhookUrl
) {
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
return true;
}
@ -146,11 +179,9 @@ class DiscordAgent implements NotificationAgent {
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = getSettings();
logger.debug('Sending discord notification', { label: 'Notifications' });
try {
const webhookUrl =
settings.notifications.agents.discord?.options?.webhookUrl;
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;

@ -1,7 +1,7 @@
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { Notification } from '..';
import path from 'path';
import { getSettings } from '../../settings';
import { getSettings, NotificationAgentEmail } from '../../settings';
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import logger from '../../../logger';
@ -9,13 +9,25 @@ import { getRepository } from 'typeorm';
import { User } from '../../../entity/User';
import { Permission } from '../../permissions';
class EmailAgent implements NotificationAgent {
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
implements NotificationAgent {
protected getSettings(): NotificationAgentEmail {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.email;
}
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
const settings = getSettings();
const settings = this.getSettings();
if (settings.notifications.agents.email.enabled) {
if (settings.enabled) {
return true;
}
@ -23,7 +35,7 @@ class EmailAgent implements NotificationAgent {
}
private getSmtpTransport() {
const emailSettings = getSettings().notifications.agents.email.options;
const emailSettings = this.getSettings().options;
return nodemailer.createTransport({
host: emailSettings.smtpHost,
@ -40,7 +52,7 @@ class EmailAgent implements NotificationAgent {
}
private getNewEmail() {
const settings = getSettings().notifications.agents.email;
const settings = this.getSettings();
return new Email({
message: {
from: settings.options.emailFrom,
@ -51,7 +63,8 @@ class EmailAgent implements NotificationAgent {
}
private async sendMediaRequestEmail(payload: NotificationPayload) {
const settings = getSettings().main;
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -76,7 +89,7 @@ class EmailAgent implements NotificationAgent {
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.applicationUrl,
actionUrl: applicationUrl,
requestType: 'New Request',
},
});
@ -92,7 +105,8 @@ class EmailAgent implements NotificationAgent {
}
private async sendMediaApprovedEmail(payload: NotificationPayload) {
const settings = getSettings().main;
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = this.getNewEmail();
@ -110,7 +124,7 @@ class EmailAgent implements NotificationAgent {
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.applicationUrl,
actionUrl: applicationUrl,
requestType: 'Request Approved',
},
});
@ -125,7 +139,8 @@ class EmailAgent implements NotificationAgent {
}
private async sendMediaAvailableEmail(payload: NotificationPayload) {
const settings = getSettings().main;
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = this.getNewEmail();
@ -143,7 +158,7 @@ class EmailAgent implements NotificationAgent {
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: settings.applicationUrl,
actionUrl: applicationUrl,
requestType: 'Now Available',
},
});
@ -157,6 +172,32 @@ class EmailAgent implements NotificationAgent {
}
}
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const email = this.getNewEmail();
email.send({
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: payload.notifyUser.email,
},
locals: {
body: payload.message,
actionUrl: applicationUrl,
},
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
public async send(
type: Notification,
payload: NotificationPayload
@ -173,6 +214,9 @@ class EmailAgent implements NotificationAgent {
case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload);
break;
case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload);
break;
}
return true;

@ -5,6 +5,7 @@ export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16,
}
class NotificationManager {

@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Permission } from './permissions';
export interface Library {
id: string;
@ -47,24 +48,25 @@ export interface SonarrSettings extends DVRSettings {
export interface MainSettings {
apiKey: string;
applicationUrl: string;
defaultPermissions: number;
}
interface PublicSettings {
initialized: boolean;
}
interface NotificationAgent {
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
options: Record<string, unknown>;
}
interface NotificationAgentDiscord extends NotificationAgent {
export interface NotificationAgentDiscord extends NotificationAgentConfig {
options: {
webhookUrl: string;
};
}
interface NotificationAgentEmail extends NotificationAgent {
export interface NotificationAgentEmail extends NotificationAgentConfig {
options: {
emailFrom: string;
smtpHost: string;
@ -105,6 +107,7 @@ class Settings {
main: {
apiKey: '',
applicationUrl: '',
defaultPermissions: Permission.REQUEST,
},
plex: {
name: '',

@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLastSeasonChangeMedia1608477467935
implements MigrationInterface {
name = 'AddLastSeasonChangeMedia1608477467935';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_b4e05e8b45c9cc64e047db95463" UNIQUE ("imdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
}

@ -5,6 +5,7 @@ import PlexTvAPI from '../api/plextv';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
import logger from '../logger';
import { getSettings } from '../lib/settings';
const authRoutes = Router();
@ -25,6 +26,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
});
authRoutes.post('/login', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
const body = req.body as { authToken?: string };
@ -69,44 +71,48 @@ authRoutes.post('/login', async (req, res, next) => {
await userRepository.save(user);
}
// If we get to this point, the user does not already exist so we need to create the
// user _assuming_ they have access to the plex server
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (await mainPlexTv.checkUserAccess(account)) {
user = new User({
email: account.email,
username: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: Permission.REQUEST,
avatar: account.thumb,
});
await userRepository.save(user);
} else {
logger.info(
'Failed login attempt from user without access to plex server',
{
label: 'Auth',
account: {
...account,
authentication_token: '__REDACTED__',
authToken: '__REDACTED__',
},
}
);
return next({
status: 403,
message: 'You do not have access to this Plex server',
// Double check that we didn't create the first admin user before running this
if (!user) {
// If we get to this point, the user does not already exist so we need to create the
// user _assuming_ they have access to the plex server
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
if (await mainPlexTv.checkUserAccess(account)) {
user = new User({
email: account.email,
username: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: settings.main.defaultPermissions,
avatar: account.thumb,
});
await userRepository.save(user);
} else {
logger.info(
'Failed login attempt from user without access to plex server',
{
label: 'Auth',
account: {
...account,
authentication_token: '__REDACTED__',
authToken: '__REDACTED__',
},
}
);
return next({
status: 403,
message: 'You do not have access to this Plex server',
});
}
}
}
// Set logged in session
if (req.session && user) {
if (req.session) {
req.session.userId = user.id;
}

@ -16,11 +16,14 @@ import logger from '../logger';
import { scheduledJobs } from '../job/schedule';
import { Permission } from '../lib/permissions';
import { isAuthenticated } from '../middleware/auth';
import { merge } from 'lodash';
import { merge, omit } from 'lodash';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import { getAppVersion } from '../utils/appVersion';
import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
import { Notification } from '../lib/notifications';
import DiscordAgent from '../lib/notifications/agents/discord';
import EmailAgent from '../lib/notifications/agents/email';
const settingsRoutes = Router();
@ -29,9 +32,7 @@ const filteredMainSettings = (
main: MainSettings
): Partial<MainSettings> => {
if (!user?.hasPermission(Permission.ADMIN)) {
return {
applicationUrl: main.applicationUrl,
};
return omit(main, 'apiKey');
}
return main;
@ -448,6 +449,25 @@ settingsRoutes.post('/notifications/discord', (req, res) => {
res.status(200).json(settings.notifications.agents.discord);
});
settingsRoutes.post('/notifications/discord/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const discordAgent = new DiscordAgent(req.body);
discordAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
settingsRoutes.get('/notifications/email', (_req, res) => {
const settings = getSettings();
@ -463,6 +483,25 @@ settingsRoutes.post('/notifications/email', (req, res) => {
res.status(200).json(settings.notifications.agents.email);
});
settingsRoutes.post('/notifications/email/test', (req, res, next) => {
if (!req.user) {
return next({
status: 500,
message: 'User information missing from request',
});
}
const emailAgent = new EmailAgent(req.body);
emailAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
});
return res.status(204).send();
});
settingsRoutes.get('/about', async (req, res) => {
const mediaRepository = getRepository(Media);
const mediaRequestRepository = getRepository(MediaRequest);

@ -1,8 +1,10 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import PlexTvAPI from '../api/plextv';
import { MediaRequest } from '../entity/MediaRequest';
import { User } from '../entity/User';
import { hasPermission, Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
import logger from '../logger';
const router = Router();
@ -142,4 +144,51 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => {
}
});
router.post('/import-from-plex', async (req, res, next) => {
try {
const settings = getSettings();
const userRepository = getRepository(User);
// taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
const plexUsersResponse = await mainPlexTv.getUsers();
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
const user = await userRepository.findOne({
where: { plexId: account.id },
});
if (user) {
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.username = account.username;
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (account.email && account.username) {
const newUser = new User({
username: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
});
await userRepository.save(newUser);
createdUsers.push(newUser);
}
}
}
return res.status(201).json(User.filterMany(createdUsers));
} catch (e) {
next({ status: 500, message: e.message });
}
});
export default router;

@ -0,0 +1,96 @@
doctype html
head
meta(charset='utf-8')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap' rel='stylesheet' media='screen')
//if mso
xml
o:officedocumentsettings
o:pixelsperinch 96
style.
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Segoe UI', sans-serif;
mso-line-height-rule: exactly;
}
style.
@media (max-width: 600px) {
.sm-w-full {
width: 100% !important;
}
}
div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style="\
background-color: #f2f4f6;\
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
width: 100%;\
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
')
a(href=actionUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
color: #a8aaaf;\
text-decoration: none;\
')
| Overseerr
tr
td(style='width: 100%' width='100%')
table.sm-w-full(align='center' style='\
background-color: #ffffff;\
margin-left: auto;\
margin-right: auto;\
width: 570px;\
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
tr
td(style='padding: 45px')
div(style='font-size: 16px')
| #{body}
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
color: #51545e;\
')
a(href=actionUrl style='color: #3869d4') Open Overseerr
tr
td
table.sm-w-full(align='center' style='\
margin-left: auto;\
margin-right: auto;\
text-align: center;\
width: 570px;\
' width='570' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='font-size: 16px; padding: 45px')
p(style='\
font-size: 13px;\
line-height: 24px;\
margin-top: 6px;\
margin-bottom: 20px;\
text-align: center;\
color: #a8aaaf;\
')
| Overseerr.

@ -0,0 +1 @@
= `Test Notification - Overseerr`

@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import Transition from '../../Transition';
@ -49,7 +48,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-50 bg-gray-800`}
>
<div className="absolute inset-0 overflow-hidden">
<section className="absolute inset-y-0 right-0 pl-10 max-w-full flex">
<section className="absolute inset-y-0 right-0 flex max-w-full pl-10">
<Transition
show={show}
appear
@ -61,20 +60,20 @@ const SlideOver: React.FC<SlideOverProps> = ({
leaveTo="translate-x-full"
>
<div className="w-screen max-w-md" ref={slideoverRef}>
<div className="h-full flex flex-col bg-gray-700 shadow-xl overflow-y-scroll">
<header className="space-y-1 py-6 px-4 bg-indigo-600 sm:px-6">
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
<header className="px-4 py-6 space-y-1 bg-indigo-600 sm:px-6">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg leading-7 font-medium text-white">
<h2 className="text-lg font-medium leading-7 text-white">
{title}
</h2>
<div className="h-7 flex items-center">
<div className="flex items-center h-7">
<button
aria-label="Close panel"
className="text-indigo-200 hover:text-white transition ease-in-out duration-150"
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<svg
className="h-6 w-6"
className="w-6 h-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@ -98,7 +97,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
</div>
)}
</header>
<div className="relative flex-1 py-6 px-4 sm:px-6 text-white">
<div className="relative flex-1 px-4 py-6 text-white sm:px-6">
{children}
</div>
</div>

@ -42,11 +42,11 @@ const MovieCast: React.FC = () => {
{intl.formatMessage(messages.fullcast)}
</Header>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.cast.map((person) => {
{data?.credits.cast.map((person, index) => {
return (
<li
key={person.id}
className="col-span-1 flex flex-col text-center items-center"
key={`cast-${person.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<PersonCard
name={person.name}

@ -0,0 +1,66 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullcrew: 'Full Crew',
});
const MovieCrew: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<MovieDetails>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<Header
subtext={
<Link href={`/movie/${data.id}`}>
<a className="hover:underline">{data.title}</a>
</Link>
}
>
{intl.formatMessage(messages.fullcrew)}
</Header>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.crew.map((person, index) => {
return (
<li
key={`crew-${person.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<PersonCard
name={person.name}
personId={person.id}
subName={person.job}
profilePath={person.profilePath}
canExpand
/>
</li>
);
})}
</ul>
</>
);
};
export default MovieCrew;

@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useMemo } from 'react';
import {
FormattedMessage,
defineMessages,
@ -38,6 +38,7 @@ import Error from '../../pages/_error';
import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
const messages = defineMessages({
releasedate: 'Release Date',
@ -67,6 +68,7 @@ const messages = defineMessages({
approve: 'Approve',
decline: 'Decline',
studio: 'Studio',
viewfullcrew: 'View Full Crew',
});
interface MovieDetailsProps {
@ -103,6 +105,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
`/api/v1/movie/${router.query.movieId}/ratings`
);
const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [
data,
]);
if (!data && !error) {
return <LoadingSpinner />;
}
@ -134,7 +140,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return (
<div
className="bg-cover bg-center -mx-4 -mt-2 px-4 sm:px-8 pt-4 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@ -159,21 +165,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
onClose={() => setShowManager(false)}
subText={data.title}
>
<h3 className="text-xl mb-2">
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="bg-gray-600 shadow overflow-hidden rounded-md">
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b last:border-b-0 border-gray-700"
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="text-center py-4 text-gray-400">
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
@ -188,21 +194,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
>
{intl.formatMessage(messages.manageModalClearMedia)}
</Button>
<div className="text-sm text-gray-400 mt-2">
<div className="mt-2 text-sm text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
<div className="md:mr-4 flex-shrink-0">
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
/>
</div>
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
@ -224,7 +230,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{data.title}{' '}
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
</h1>
<span className="text-xs md:text-base mt-1 md:mt-0">
<span className="mt-1 text-xs md:text-base md:mt-0">
{(data.runtime ?? 0) > 0 && (
<>
<FormattedMessage
@ -237,7 +243,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex-1 flex justify-end mt-4 md:mt-0">
<div className="flex justify-end flex-1 mt-4 md:mt-0">
{(!data.mediaInfo ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button
@ -382,7 +388,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</div>
</div>
<div className="flex pt-8 text-white flex-col md:flex-row pb-4">
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
<FormattedMessage {...messages.overview} />
@ -392,11 +398,49 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
{sortedCrew.slice(0, 6).map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
</a>
</Link>
</li>
))}
</ul>
{sortedCrew.length > 0 && (
<div className="flex justify-end mt-4">
<Link href={`/movie/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<svg
className="inline-block w-5 h-5 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
)}
</div>
<div className="w-full md:w-80 mt-8 md:mt-0">
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
{(data.voteCount > 0 || ratingData) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
{ratingData?.criticsRating &&
(ratingData?.criticsScore ?? 0) > 0 && (
<>
@ -407,7 +451,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
@ -422,7 +466,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
@ -432,7 +476,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-gray-400 text-sm">
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
@ -443,7 +487,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.releasedate} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.releaseDate)}
year="numeric"
@ -456,7 +500,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.status} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
@ -465,7 +509,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.revenue} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedNumber
currency="USD"
style="currency"
@ -479,7 +523,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.budget} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedNumber
currency="USD"
style="currency"
@ -495,7 +539,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
@ -509,7 +553,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span className="text-sm">
<FormattedMessage {...messages.studio} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{data.productionCompanies[0]?.name}
</span>
</div>
@ -525,10 +569,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.cast} />
</span>
@ -566,13 +610,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
/>
{(recommended?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link
href="/movie/[movieId]/recommendations"
as={`/movie/${data.id}/recommendations`}
>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.recommendations} />
</span>
@ -616,13 +660,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
{(similar?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link
href="/movie/[movieId]/similar"
as={`/movie/${data.id}/similar`}
>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.similar} />
</span>

@ -25,18 +25,19 @@ const PersonCard: React.FC<PersonCardProps> = ({
} bg-gray-600 rounded-lg text-white shadow-lg hover:bg-gray-500 transition ease-in-out duration-150 cursor-pointer`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
{profilePath && (
<div
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath})`,
}}
className="rounded-full w-28 h-28 md:w-32 md:h-32 bg-cover bg-center mb-6"
/>
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
<img
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
alt=""
/>
</div>
)}
{!profilePath && (
<svg
className="w-28 h-28 md:w-32 md:h-32 mb-6"
className="mb-6 w-28 h-28 md:w-32 md:h-32"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
@ -48,12 +49,21 @@ const PersonCard: React.FC<PersonCardProps> = ({
/>
</svg>
)}
<div className="whitespace-normal text-center">{name}</div>
<div className="w-full text-center truncate">{name}</div>
{subName && (
<div className="whitespace-normal text-center text-sm text-gray-300">
<div
className="overflow-hidden text-sm text-center text-gray-300 whitespace-normal"
style={{
WebkitLineClamp: 2,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{subName}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-600" />
</div>
</div>
</div>

@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import useSWR from 'swr';
import type { PersonDetail } from '../../../server/models/Person';
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
@ -11,6 +11,7 @@ import { LanguageContext } from '../../context/LanguageContext';
const messages = defineMessages({
appearsin: 'Appears in',
crewmember: 'Crew Member',
ascharacter: 'as {character}',
nobiography: 'No biography available.',
});
@ -22,6 +23,7 @@ const PersonDetails: React.FC = () => {
const { data, error } = useSWR<PersonDetail>(
`/api/v1/person/${router.query.personId}`
);
const [showBio, setShowBio] = useState(false);
const {
data: combinedCredits,
@ -53,77 +55,151 @@ const PersonDetails: React.FC = () => {
return 1;
});
const sortedCrew = combinedCredits?.crew.sort((a, b) => {
const aDate =
a.mediaType === 'movie'
? a.releaseDate?.slice(0, 4) ?? 0
: a.firstAirDate?.slice(0, 4) ?? 0;
const bDate =
b.mediaType === 'movie'
? b.releaseDate?.slice(0, 4) ?? 0
: b.firstAirDate?.slice(0, 4) ?? 0;
if (aDate > bDate) {
return -1;
}
return 1;
});
const isLoading = !combinedCredits && !errorCombinedCredits;
return (
<>
<div className="flex mt-8 mb-8 flex-col md:flex-row items-center md:items-start">
<div className="flex flex-col items-center mt-8 mb-8 md:flex-row md:items-start">
{data.profilePath && (
<div
style={{
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`,
}}
className="rounded-full w-36 h-36 md:w-44 md:h-44 bg-cover bg-center mb-6 md:mb-0 mr-0 md:mr-6 flex-shrink-0"
className="flex-shrink-0 mb-6 mr-0 bg-center bg-cover rounded-full w-36 h-36 md:w-44 md:h-44 md:mb-0 md:mr-6"
/>
)}
<div className="text-gray-300 text-center md:text-left">
<h1 className="text-3xl md:text-4xl text-white mb-4">{data.name}</h1>
<div>
{data.biography
? data.biography
: intl.formatMessage(messages.nobiography)}
<div className="text-center text-gray-300 md:text-left">
<h1 className="mb-4 text-3xl text-white md:text-4xl">{data.name}</h1>
<div className="relative">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className={`transition-max-height duration-300 ${
showBio
? 'overflow-visible extra-max-height'
: 'overflow-hidden max-h-44'
}`}
onClick={() => setShowBio((show) => !show)}
role="button"
tabIndex={-1}
>
<div className={showBio ? 'h-auto' : 'h-36'}>
{data.biography
? data.biography
: intl.formatMessage(messages.nobiography)}
</div>
{!showBio && (
<div className="absolute bottom-0 left-0 right-0 w-full h-8 bg-gradient-to-t from-gray-900" />
)}
</div>
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="flex-1 min-w-0">
<div className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<span>{intl.formatMessage(messages.appearsin)}</span>
{(sortedCast ?? []).length > 0 && (
<>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCast?.map((media) => {
return (
<li
key={`list-cast-item-${media.id}`}
className="col-span-1 flex flex-col text-center items-center"
>
<TitleCard
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 text-gray-300 text-xs truncate w-36 sm:w-36 md:w-44 text-center">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
</div>
)}
</li>
);
})}
{isLoading &&
[...Array(20)].map((_item, i) => (
<li
key={`placeholder-${i}`}
className="col-span-1 flex flex-col text-center items-center"
>
<TitleCard.Placeholder canExpand />
</li>
))}
</ul>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCast?.map((media, index) => {
return (
<li
key={`list-cast-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={
media.mediaType === 'movie' ? media.title : media.name
}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
</div>
)}
</li>
);
})}
</ul>
</>
)}
{(sortedCrew ?? []).length > 0 && (
<>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCrew?.map((media, index) => {
return (
<li
key={`list-crew-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={
media.mediaType === 'movie' ? media.title : media.name
}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.job && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{media.job}
</div>
)}
</li>
);
})}
</ul>
</>
)}
{isLoading && <LoadingSpinner />}
</>
);
};

@ -4,7 +4,7 @@ import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Button from '../../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import Axios from 'axios';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
@ -17,6 +17,8 @@ const messages = defineMessages({
webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks',
discordsettingssaved: 'Discord notification settings saved!',
discordsettingsfailed: 'Discord notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
});
const NotificationsDiscord: React.FC = () => {
@ -46,7 +48,7 @@ const NotificationsDiscord: React.FC = () => {
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
try {
await Axios.post('/api/v1/settings/notifications/discord', {
await axios.post('/api/v1/settings/notifications/discord', {
enabled: values.enabled,
types: values.types,
options: {
@ -67,7 +69,22 @@ const NotificationsDiscord: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting }) => {
{({ errors, touched, isSubmitting, values, isValid }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/discord/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
@ -112,11 +129,24 @@ const NotificationsDiscord: React.FC = () => {
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.saving)

@ -4,7 +4,7 @@ import useSWR from 'swr';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Button from '../../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import Axios from 'axios';
import axios from 'axios';
import * as Yup from 'yup';
import { useToasts } from 'react-toast-notifications';
@ -23,6 +23,8 @@ const messages = defineMessages({
authPass: 'Auth Pass',
emailsettingssaved: 'Email notification settings saved!',
emailsettingsfailed: 'Email notification settings failed to save.',
test: 'Test',
testsent: 'Test notification sent!',
});
const NotificationsEmail: React.FC = () => {
@ -63,7 +65,7 @@ const NotificationsEmail: React.FC = () => {
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
try {
await Axios.post('/api/v1/settings/notifications/email', {
await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled,
types: values.types,
options: {
@ -89,7 +91,27 @@ const NotificationsEmail: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting }) => {
{({ errors, touched, isSubmitting, values, isValid }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true,
types: values.types,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
smtpPort: Number(values.smtpPort),
secure: values.secure,
authUser: values.authUser,
authPass: values.authPass,
},
});
addToast(intl.formatMessage(messages.testsent), {
appearance: 'info',
autoDismiss: true,
});
};
return (
<Form>
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
@ -228,11 +250,24 @@ const NotificationsEmail: React.FC = () => {
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{intl.formatMessage(messages.test)}
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting}
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.saving)

@ -26,16 +26,12 @@ const SettingsAbout: React.FC = () => {
'/api/v1/settings/about'
);
if (error) {
return <Error statusCode={500} />;
}
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <LoadingSpinner />;
return <Error statusCode={500} />;
}
return (

@ -9,6 +9,8 @@ import Button from '../Common/Button';
import { defineMessages, useIntl } from 'react-intl';
import { useUser, Permission } from '../../hooks/useUser';
import { useToasts } from 'react-toast-notifications';
import { messages as permissionMessages } from '../UserEdit';
import { hasPermission } from '../../../server/lib/permissions';
const messages = defineMessages({
generalsettings: 'General Settings',
@ -22,11 +24,19 @@ const messages = defineMessages({
toastApiKeyFailure: 'Something went wrong generating a new API Key.',
toastSettingsSuccess: 'Settings saved.',
toastSettingsFailure: 'Something went wrong saving settings.',
defaultPermissions: 'Default User Permissions',
});
interface PermissionOption {
id: string;
name: string;
description: string;
permission: Permission;
}
const SettingsMain: React.FC = () => {
const { addToast } = useToasts();
const { hasPermission } = useUser();
const { hasPermission: userHasPermission } = useUser();
const intl = useIntl();
const { data, error, revalidate } = useSWR<MainSettings>(
'/api/v1/settings/main'
@ -53,13 +63,62 @@ const SettingsMain: React.FC = () => {
return <LoadingSpinner />;
}
const permissionList: PermissionOption[] = [
{
id: 'admin',
name: intl.formatMessage(permissionMessages.admin),
description: intl.formatMessage(permissionMessages.adminDescription),
permission: Permission.ADMIN,
},
{
id: 'settings',
name: intl.formatMessage(permissionMessages.settings),
description: intl.formatMessage(permissionMessages.settingsDescription),
permission: Permission.MANAGE_SETTINGS,
},
{
id: 'users',
name: intl.formatMessage(permissionMessages.users),
description: intl.formatMessage(permissionMessages.usersDescription),
permission: Permission.MANAGE_USERS,
},
{
id: 'managerequest',
name: intl.formatMessage(permissionMessages.managerequests),
description: intl.formatMessage(
permissionMessages.managerequestsDescription
),
permission: Permission.MANAGE_REQUESTS,
},
{
id: 'request',
name: intl.formatMessage(permissionMessages.request),
description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'vote',
name: intl.formatMessage(permissionMessages.vote),
description: intl.formatMessage(permissionMessages.voteDescription),
permission: Permission.VOTE,
},
{
id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove),
description: intl.formatMessage(
permissionMessages.autoapproveDescription
),
permission: Permission.AUTO_APPROVE,
},
];
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
{intl.formatMessage(messages.generalsettings)}
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
{intl.formatMessage(messages.generalsettingsDescription)}
</p>
</div>
@ -67,11 +126,14 @@ const SettingsMain: React.FC = () => {
<Formik
initialValues={{
applicationUrl: data?.applicationUrl,
defaultPermissions: data?.defaultPermissions ?? 0,
}}
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/main', {
applicationUrl: values.applicationUrl,
defaultPermissions: values.defaultPermissions,
});
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
@ -88,10 +150,10 @@ const SettingsMain: React.FC = () => {
}
}}
>
{({ isSubmitting }) => {
{({ isSubmitting, values, setFieldValue }) => {
return (
<Form>
{hasPermission(Permission.ADMIN) && (
{userHasPermission(Permission.ADMIN) && (
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
<label
htmlFor="username"
@ -100,11 +162,11 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.apikey)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<input
type="text"
id="apiKey"
className="flex-1 form-input block w-full min-w-0 rounded-none rounded-l-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-none form-input rounded-l-md sm:text-sm sm:leading-5"
value={data?.apiKey}
readOnly
/>
@ -117,7 +179,7 @@ const SettingsMain: React.FC = () => {
e.preventDefault();
regenerate();
}}
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium rounded-r-md text-white bg-indigo-500 hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
className="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-500 border border-gray-500 rounded-r-md hover:bg-indigo-400 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700"
>
<svg
className="w-5 h-5"
@ -144,20 +206,98 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.applicationurl)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="max-w-lg flex rounded-md shadow-sm">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="applicationUrl"
name="applicationUrl"
type="text"
placeholder="https://os.example.com"
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
/>
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="mt-6">
<div role="group" aria-labelledby="label-permissions">
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
id="label-permissions"
>
{intl.formatMessage(messages.defaultPermissions)}
</div>
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
{permissionList.map((permissionOption) => (
<div
className={`relative flex items-start first:mt-0 mt-4 ${
permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
values.defaultPermissions
)
? 'opacity-50'
: ''
}`}
key={`permission-option-${permissionOption.id}`}
>
<div className="flex items-center h-5">
<input
id={permissionOption.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
values.defaultPermissions
)
}
onClick={() => {
setFieldValue(
'defaultPermissions',
hasPermission(
permissionOption.permission,
values.defaultPermissions
)
? values.defaultPermissions -
permissionOption.permission
: values.defaultPermissions +
permissionOption.permission
);
}}
checked={hasPermission(
permissionOption.permission,
values.defaultPermissions
)}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label
htmlFor={permissionOption.id}
className="font-medium"
>
{permissionOption.name}
</label>
<p className="text-gray-500">
{permissionOption.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

@ -0,0 +1,66 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { TvDetails } from '../../../../server/models/Tv';
import { LanguageContext } from '../../../context/LanguageContext';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullseriescrew: 'Full Series Crew',
});
const TvCrew: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const { data, error } = useSWR<TvDetails>(
`/api/v1/tv/${router.query.tvId}?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<Header
subtext={
<Link href={`/tv/${data.id}`}>
<a className="hover:underline">{data.name}</a>
</Link>
}
>
{intl.formatMessage(messages.fullseriescrew)}
</Header>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{data?.credits.crew.map((person, index) => {
return (
<li
key={`crew-${person.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<PersonCard
name={person.name}
personId={person.id}
subName={person.job}
profilePath={person.profilePath}
canExpand
/>
</li>
);
})}
</ul>
</>
);
};
export default TvCrew;

@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useMemo } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { useRouter } from 'next/router';
@ -30,6 +30,8 @@ import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common';
const messages = defineMessages({
userrating: 'User Rating',
@ -61,6 +63,7 @@ const messages = defineMessages({
showtype: 'Show Type',
anime: 'Anime',
network: 'Network',
viewfullcrew: 'View Full Crew',
});
interface TvDetailsProps {
@ -105,6 +108,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
`/api/v1/tv/${router.query.tvId}/ratings`
);
const sortedCrew = useMemo(() => sortCrewPriority(data?.credits.crew ?? []), [
data,
]);
if (!data && !error) {
return <LoadingSpinner />;
}
@ -148,7 +155,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return (
<div
className="bg-cover bg-center -mx-4 -mt-2 px-4 sm:px-8 pt-4 "
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@ -173,21 +180,21 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
onClose={() => setShowManager(false)}
subText={data.name}
>
<h3 className="text-xl mb-2">
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="bg-gray-600 shadow overflow-hidden rounded-md">
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b last:border-b-0 border-gray-700"
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="text-center py-4 text-gray-400">
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
@ -202,21 +209,21 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
>
{intl.formatMessage(messages.manageModalClearMedia)}
</Button>
<div className="text-sm text-gray-400 mt-2">
<div className="mt-2 text-sm text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
<div className="md:mr-4 flex-shrink-0">
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
/>
</div>
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
@ -242,16 +249,16 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<h1 className="text-2xl md:text-4xl">
<span>{data.name}</span>
{data.firstAirDate && (
<span className="text-2xl ml-2">
<span className="ml-2 text-2xl">
({data.firstAirDate.slice(0, 4)})
</span>
)}
</h1>
<span className="text-xs md:text-base mt-1 md:mt-0">
<span className="mt-1 text-xs md:text-base md:mt-0">
{data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex-1 flex justify-end mt-4 md:mt-0">
<div className="flex justify-end flex-1 mt-4 md:mt-0">
{(!data.mediaInfo ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button
@ -391,7 +398,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
</div>
</div>
<div className="flex pt-8 text-white flex-col md:flex-row pb-4">
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
<FormattedMessage {...messages.overview} />
@ -401,11 +408,63 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
{(data.createdBy.length > 0
? [
...data.createdBy.map(
(person): Partial<Crew> => ({
id: person.id,
job: 'Creator',
name: person.name,
})
),
...sortedCrew,
]
: sortedCrew
)
.slice(0, 6)
.map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
</a>
</Link>
</li>
))}
</ul>
{sortedCrew.length > 0 && (
<div className="flex justify-end mt-4">
<Link href={`/tv/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<svg
className="inline-block w-5 h-5 ml-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
)}
</div>
<div className="w-full md:w-80 mt-8 md:mt-0">
<div className="bg-gray-900 rounded-lg shadow border border-gray-800">
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
{(data.voteCount > 0 || ratingData) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0 items-center justify-center">
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
{ratingData?.criticsRating &&
(ratingData?.criticsScore ?? 0) > 0 && (
<>
@ -416,7 +475,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
@ -431,7 +490,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="text-gray-400 text-sm mr-4 last:mr-0">
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
@ -441,7 +500,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<span className="text-sm">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-gray-400 text-sm">
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
@ -455,7 +514,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<span className="text-sm">
{intl.formatMessage(messages.showtype)}
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{intl.formatMessage(messages.anime)}
</span>
</div>
@ -464,7 +523,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<span className="text-sm">
<FormattedMessage {...messages.status} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
@ -475,7 +534,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<span className="text-sm">
<FormattedMessage {...messages.originallanguage} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{
data.spokenLanguages.find(
(lng) => lng.iso_639_1 === data.originalLanguage
@ -489,7 +548,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<span className="text-sm">
<FormattedMessage {...messages.network} />
</span>
<span className="flex-1 text-right text-gray-400 text-sm">
<span className="flex-1 text-sm text-right text-gray-400">
{data.networks.map((n) => n.name).join(', ')}
</span>
</div>
@ -505,10 +564,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
</div>
</div>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/tv/[tvId]/cast" as={`/tv/${data.id}/cast`}>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.cast} />
</span>
@ -546,13 +605,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
/>
{(recommended?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link
href="/tv/[tvId]/recommendations"
as={`/tv/${data.id}/recommendations`}
>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.recommendations} />
</span>
@ -596,10 +655,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
{(similar?.results ?? []).length > 0 && (
<>
<div className="md:flex md:items-center md:justify-between mb-4 mt-6">
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/tv/[tvId]/similar" as={`/tv/${data.id}/similar`}>
<a className="inline-flex text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate items-center">
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>
<FormattedMessage {...messages.similar} />
</span>

@ -9,7 +9,7 @@ import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import Header from '../Common/Header';
const messages = defineMessages({
export const messages = defineMessages({
edituser: 'Edit User',
username: 'Username',
avatar: 'Avatar',
@ -148,7 +148,7 @@ const UserEdit: React.FC = () => {
<FormattedMessage {...messages.edituser} />
</Header>
<div className="px-4 space-y-6 sm:p-6 lg:pb-8">
<div className="flex flex-col space-y-6 lg:flex-row lg:space-y-0 lg:space-x-6 text-white">
<div className="flex flex-col space-y-6 text-white lg:flex-row lg:space-y-0 lg:space-x-6">
<div className="flex-grow space-y-6">
<div className="space-y-1">
<label
@ -157,11 +157,11 @@ const UserEdit: React.FC = () => {
>
<FormattedMessage {...messages.username} />
</label>
<div className="rounded-md shadow-sm flex">
<div className="flex rounded-md shadow-sm">
<input
id="username"
type="text"
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-grow block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
value={user?.username}
readOnly
/>
@ -174,11 +174,11 @@ const UserEdit: React.FC = () => {
>
<FormattedMessage {...messages.email} />
</label>
<div className="rounded-md shadow-sm flex">
<div className="flex rounded-md shadow-sm">
<input
id="email"
type="text"
className="form-input flex-grow block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
className="flex-grow block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
value={user?.email}
readOnly
/>
@ -188,7 +188,7 @@ const UserEdit: React.FC = () => {
<div className="flex-grow space-y-1 lg:flex-grow-0 lg:flex-shrink-0">
<p
className="block text-sm leading-5 font-medium text-gray-400"
className="block text-sm font-medium leading-5 text-gray-400"
aria-hidden="true"
>
<FormattedMessage {...messages.avatar} />
@ -196,11 +196,11 @@ const UserEdit: React.FC = () => {
<div className="lg:hidden">
<div className="flex items-center">
<div
className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12"
className="flex-shrink-0 inline-block w-12 h-12 overflow-hidden rounded-full"
aria-hidden="true"
>
<img
className="rounded-full h-full w-full"
className="w-full h-full rounded-full"
src={user?.avatar}
alt=""
/>
@ -208,9 +208,9 @@ const UserEdit: React.FC = () => {
</div>
</div>
<div className="hidden relative rounded-full overflow-hidden lg:block transition duration-150 ease-in-out">
<div className="relative hidden overflow-hidden transition duration-150 ease-in-out rounded-full lg:block">
<img
className="relative rounded-full w-40 h-40"
className="relative w-40 h-40 rounded-full"
src={user?.avatar}
alt=""
/>
@ -223,7 +223,7 @@ const UserEdit: React.FC = () => {
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
<div>
<div
className="text-base leading-6 font-medium sm:text-sm sm:leading-5"
className="text-base font-medium leading-6 sm:text-sm sm:leading-5"
id="label-permissions"
>
<FormattedMessage {...messages.permissions} />
@ -254,7 +254,7 @@ const UserEdit: React.FC = () => {
id={permissionOption.id}
name="permissions"
type="checkbox"
className="form-checkbox h-4 w-4 rounded-md text-indigo-600 transition duration-150 ease-in-out"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
(permissionOption.permission !==
Permission.ADMIN &&
@ -305,9 +305,9 @@ const UserEdit: React.FC = () => {
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

@ -18,6 +18,10 @@ import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
userlist: 'User List',
importfromplex: 'Import Users From Plex',
importfromplexerror: 'Something went wrong importing users from Plex',
importedfromplex:
'{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex',
username: 'Username',
totalrequests: 'Total Requests',
usertype: 'User Type',
@ -42,6 +46,7 @@ const UserList: React.FC = () => {
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR<User[]>('/api/v1/user');
const [isDeleting, setDeleting] = useState(false);
const [isImporting, setImporting] = useState(false);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user?: User;
@ -66,10 +71,38 @@ const UserList: React.FC = () => {
appearance: 'error',
});
} finally {
setDeleting(false);
revalidate();
}
};
const importFromPlex = async () => {
setImporting(true);
try {
const { data: createdUsers } = await axios.post(
'/api/v1/user/import-from-plex'
);
addToast(
intl.formatMessage(messages.importedfromplex, {
userCount: createdUsers.length,
}),
{
autoDismiss: true,
appearance: 'success',
}
);
} catch (e) {
addToast(intl.formatMessage(messages.importfromplexerror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidate();
setImporting(false);
}
};
if (!data && !error) {
return <LoadingSpinner />;
}
@ -116,7 +149,17 @@ const UserList: React.FC = () => {
{intl.formatMessage(messages.deleteconfirm)}
</Modal>
</Transition>
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<div className="flex items-center justify-between">
<Header extraMargin={4}>{intl.formatMessage(messages.userlist)}</Header>
<Button
className="mx-4 my-8"
buttonType="primary"
disabled={isImporting}
onClick={() => importFromPlex()}
>
{intl.formatMessage(messages.importfromplex)}
</Button>
</div>
<Table>
<thead>
<tr>
@ -134,18 +177,18 @@ const UserList: React.FC = () => {
<tr key={`user-list-${user.id}`}>
<Table.TD>
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="flex-shrink-0 w-10 h-10">
<img
className="h-10 w-10 rounded-full"
className="w-10 h-10 rounded-full"
src={user.avatar}
alt=""
/>
</div>
<div className="ml-4">
<div className="text-sm leading-5 font-medium">
<div className="text-sm font-medium leading-5">
{user.username}
</div>
<div className="text-sm leading-5 text-gray-300">
<div className="text-sm text-gray-300 leading-5">
{user.email}
</div>
</div>

@ -7,8 +7,20 @@ import type { Nullable } from '../utils/typeHelpers';
type Url = string | UrlObject;
const extraEncodes: [RegExp, string][] = [
[/\(/g, '%28'],
[/\)/g, '%29'],
[/!/g, '%21'],
];
const encodeURIExtraParams = (string: string): string => {
return encodeURIComponent(string).replace(/!/g, '%21');
let finalString = encodeURIComponent(string);
extraEncodes.forEach((encode) => {
finalString = finalString.replace(encode[0], encode[1]);
});
return finalString;
};
interface SearchObject {

@ -19,6 +19,7 @@
"components.Layout.alphawarning": "This is ALPHA software. Almost everything is bound to be nearly broken and/or unstable. Please report issues to the Overseerr GitHub!",
"components.Login.signinplex": "Sign in to continue",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
"components.MovieDetails.approve": "Approve",
"components.MovieDetails.available": "Available",
"components.MovieDetails.budget": "Budget",
@ -46,9 +47,11 @@
"components.MovieDetails.studio": "Studio",
"components.MovieDetails.unavailable": "Unavailable",
"components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member",
"components.PersonDetails.nobiography": "No biography available.",
"components.PlexLoginButton.loading": "Loading…",
"components.PlexLoginButton.loggingin": "Logging in…",
@ -102,6 +105,8 @@
"components.Settings.Notifications.saving": "Saving…",
"components.Settings.Notifications.smtpHost": "SMTP Host",
"components.Settings.Notifications.smtpPort": "SMTP Port",
"components.Settings.Notifications.test": "Test",
"components.Settings.Notifications.testsent": "Test notification sent!",
"components.Settings.Notifications.validationFromRequired": "You must provide an email sender address",
"components.Settings.Notifications.validationSmtpHostRequired": "You must provide an SMTP host",
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide an SMTP port",
@ -212,6 +217,7 @@
"components.Settings.currentlibrary": "Current Library: {name}",
"components.Settings.default": "Default",
"components.Settings.default4k": "Default 4K",
"components.Settings.defaultPermissions": "Default User Permissions",
"components.Settings.delete": "Delete",
"components.Settings.deleteserverconfirm": "Are you sure you want to delete this server?",
"components.Settings.edit": "Edit",
@ -274,6 +280,7 @@
"components.TitleCard.movie": "Movie",
"components.TitleCard.tvshow": "Series",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
@ -302,6 +309,7 @@
"components.TvDetails.status": "Status",
"components.TvDetails.unavailable": "Unavailable",
"components.TvDetails.userrating": "User Rating",
"components.TvDetails.viewfullcrew": "View Full Crew",
"components.UserEdit.admin": "Admin",
"components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.",
"components.UserEdit.autoapprove": "Auto Approve",
@ -331,6 +339,9 @@
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.",
"components.UserList.deleteuser": "Delete User",
"components.UserList.edit": "Edit",
"components.UserList.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex",
"components.UserList.importfromplex": "Import Users From Plex",
"components.UserList.importfromplexerror": "Something went wrong importing users from Plex",
"components.UserList.lastupdated": "Last Updated",
"components.UserList.plexuser": "Plex User",
"components.UserList.role": "Role",

@ -0,0 +1,9 @@
import { NextPage } from 'next';
import React from 'react';
import MovieCrew from '../../../components/MovieDetails/MovieCrew';
const MovieCrewPage: NextPage = () => {
return <MovieCrew />;
};
export default MovieCrewPage;

@ -0,0 +1,9 @@
import { NextPage } from 'next';
import React from 'react';
import TvCrew from '../../../components/TvDetails/TvCrew';
const TvCrewPage: NextPage = () => {
return <TvCrew />;
};
export default TvCrewPage;

@ -7,7 +7,7 @@ body {
}
.plex-button {
@apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center disabled:opacity-50;
@apply flex justify-center w-full px-4 py-2 text-sm font-medium text-center text-white transition duration-150 ease-in-out bg-indigo-600 border border-transparent rounded-md disabled:opacity-50;
background-color: #cc7b19;
}
@ -16,7 +16,7 @@ body {
}
.titleCard {
@apply relative bg-cover rounded-lg bg-gray-800;
@apply relative bg-gray-800 bg-cover rounded-lg;
padding-bottom: 150%;
}
@ -34,7 +34,12 @@ body {
}
.error-message {
@apply flex items-center justify-center text-center text-gray-300 relative top-0 left-0 bottom-0 right-0 h-screen flex-col;
@apply relative top-0 bottom-0 left-0 right-0 flex flex-col items-center justify-center h-screen text-center text-gray-300;
}
/* Used for animating height */
.extra-max-height {
max-height: 100rem;
}
/* Hide scrollbar for Chrome, Safari and Opera */
@ -49,5 +54,5 @@ body {
}
code {
@apply bg-gray-800 py-1 px-2 rounded-md;
@apply px-2 py-1 bg-gray-800 rounded-md;
}

@ -0,0 +1,24 @@
import { Crew } from '../../server/models/common';
const priorityJobs = [
'Director',
'Creator',
'Screenplay',
'Writer',
'Composer',
'Editor',
'Producer',
'Co-Producer',
'Executive Producer',
'Animation',
];
export const sortCrewPriority = (crew: Crew[]): Crew[] => {
return crew
.filter((person) => priorityJobs.includes(person.job))
.sort((a, b) => {
const aScore = priorityJobs.findIndex((job) => job.includes(a.job));
const bScore = priorityJobs.findIndex((job) => job.includes(b.job));
return aScore - bScore;
});
};

@ -5,6 +5,9 @@ module.exports = {
purge: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'],
theme: {
extend: {
transitionProperty: {
'max-height': 'max-height',
},
fontFamily: {
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
},

@ -62,10 +62,10 @@
call-me-maybe "^1.0.1"
js-yaml "^3.13.1"
"@babel/cli@^7.12.8":
version "7.12.8"
resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.12.8.tgz#3b24ed2fd5da353ee6f19e8935ff8c93b5fe8430"
integrity sha512-/6nQj11oaGhLmZiuRUfxsujiPDc9BBReemiXgIbxc+M5W+MIiFKYwvNDJvBfnGKNsJTKbUfEheKc9cwoPHAVQA==
"@babel/cli@^7.12.10":
version "7.12.10"
resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.12.10.tgz#67a1015b1cd505bde1696196febf910c4c339a48"
integrity sha512-+y4ZnePpvWs1fc/LhZRTHkTesbXkyBYuOB+5CyodZqrEuETXi3zOVfpAQIdgC3lXbHLTDG9dQosxR9BhvLKDLQ==
dependencies:
commander "^4.0.1"
convert-source-map "^1.1.0"
@ -1365,26 +1365,26 @@
dependencies:
tslib "^2.0.1"
"@formatjs/intl-datetimeformat@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.1.0.tgz#d0f73a4b6147d23e08eb152c72ae06d1b0da0d9d"
integrity sha512-XKyDQ3xFgZK2w8GE2v+zE0nk/JqGKFE0UxTI716mp/+OVuws+dbQPiORfSrJceH7E3ZkfGrvO0BB8sksQNsZ+w==
"@formatjs/intl-datetimeformat@3.2.1":
version "3.2.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.2.1.tgz#f20408cda0e932f2234ecb42fca1e90d2e75250d"
integrity sha512-teeUgUoieP0JjZYPWjJV72CoPQoukCMKGW1YUu00+TaHzZBNqVgPCdFJo2vgl1jKccOAT3VT79BHNEsR9DsBBQ==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
tslib "^2.0.1"
"@formatjs/intl-displaynames@4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.1.tgz#bb6a4e7881e666907e916da6a0cb5d532d93edc0"
integrity sha512-vhG9y+F0BudHU9ev0O9Tc5Uwz/MAcCzbBzceSnjcoUMyLLfFN6GSPBvU6+ocxWsfjhu/yL5ja+doZdhwDcSXrA==
"@formatjs/intl-displaynames@4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.2.tgz#31212238e7b07daa41fbac03186c532cbbb6c473"
integrity sha512-rOlDcFzr6UFYqH7BKI9vlpDC5MpTT48dsPxO9I6yciDlOb1IyqvIgUs+xsuNOj96akDCDrgwocrdJ1VEDO0Ntw==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
tslib "^2.0.1"
"@formatjs/intl-listformat@5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.1.tgz#25994d06acc81a2a0eaae9ac59e7a2fa851be8f0"
integrity sha512-x1gqI3xvTn8uTY0W+bL4ySW/5HFeQXkNNfsdoaRtX2b/HNa4fZoU1EaA6koAk9gUAWSR5Ofe1Ps49CXaMvwcTg==
"@formatjs/intl-listformat@5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.2.tgz#090055c437bf7176a7268a285f5d06fc7e963280"
integrity sha512-Y+7/Dw3oe29kT4afbw2KCSzast6M04ibidBMMPqjxOHHxan1LeL0KQsY/iRHTgTAcfiSIqZnneJZjZi4MzjLJg==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
tslib "^2.0.1"
@ -1396,30 +1396,39 @@
dependencies:
"@formatjs/ecma402-abstract" "^1.2.1"
"@formatjs/intl-relativetimeformat@8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-8.0.0.tgz#e7234f165932a22ca6faf015b53bf7a53dbe5350"
integrity sha512-GKJvd2+Sx0BJqsKt2rBbkgGAwfBjKVnvlRTZQ+OhgSEOeRBHOtaub1jUx8ScQoS5Xe0RFLvTLL2LSnajg6EXkw==
"@formatjs/intl-relativetimeformat@8.0.1":
version "8.0.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-8.0.1.tgz#9fcad0dba673cf0e21b4b9f83dab22ca6b482901"
integrity sha512-yMCtrDeQnqx95ucaYbHc1BP4XUP0q+JoMiP8kzMe04AgVvkfAScsoRuKfXw1EH1FkV51C/vqWIKDoGj1WoZnxQ==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
tslib "^2.0.1"
"@formatjs/intl@1.4.10":
version "1.4.10"
resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.4.10.tgz#0b9b9970649630f7904f7ff930da3cdc8a897d17"
integrity sha512-CwbOmAnM2QKBUs6Eps1ry0YBe9nIQgQp9xQyxth/0BjJ8zRE3gIUzdNrLNCZ41nHuNPVFJRRIX79+yu5l+A56w==
"@formatjs/intl@1.4.13":
version "1.4.13"
resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.4.13.tgz#634e8e7d29385ade5cf7e7c0ba8aae63e585cba0"
integrity sha512-GEWwkaNFnskOGGd6gq0Y0RetiH2iNnARXzQ+glR2RqU0xk00aS5KpwkEDo1hN9NaO9fRr9UDvzDoEu9foQFVmA==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
"@formatjs/intl-datetimeformat" "3.1.0"
"@formatjs/intl-displaynames" "4.0.1"
"@formatjs/intl-listformat" "5.0.1"
"@formatjs/intl-relativetimeformat" "8.0.0"
"@formatjs/intl-datetimeformat" "3.2.1"
"@formatjs/intl-displaynames" "4.0.2"
"@formatjs/intl-listformat" "5.0.2"
"@formatjs/intl-relativetimeformat" "8.0.1"
fast-memoize "^2.5.2"
intl-messageformat "9.3.20"
intl-messageformat-parser "6.0.18"
intl-messageformat "9.4.0"
intl-messageformat-parser "6.1.0"
tslib "^2.0.1"
"@formatjs/ts-transformer@2.12.10", "@formatjs/ts-transformer@^2.6.0":
"@formatjs/ts-transformer@2.12.11":
version "2.12.11"
resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-2.12.11.tgz#fedbc44a44a7da6925d149e3051cd8a7a869d8c2"
integrity sha512-XjknAXQEy7s8Q9LsyECFo1369kctH/C841o/JeDqHRDhkgn1vV/IlF3v2qli7mxEc+L2JcO8LUwqOALpTBW/5A==
dependencies:
intl-messageformat-parser "6.1.0"
tslib "^2.0.1"
typescript "^4.0"
"@formatjs/ts-transformer@^2.6.0":
version "2.12.10"
resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-2.12.10.tgz#4f8758ea89e2536239b573da98f99454a4952ebf"
integrity sha512-H8mtPQcyXxLo3GJGkNVj3ZlmebeqxQfVTIvGsdpE1oXKZ/SxKqvC7ZeHlbZUyXUEiRwdJ4Hfsgw1QzsmTJnicw==
@ -2115,10 +2124,10 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.10.tgz#5958a82e41863cfc71f2307b3748e3491ba03785"
integrity sha512-J32dgx2hw8vXrSbu4ZlVhn1Nm3GbeCFNw2FWL8S5QKucHGY0cyNwjdQdO+KMBZ4wpmC7KhLCiNsdk1RFRIYUQQ==
"@types/node@^14.14.11":
version "14.14.11"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.11.tgz#fc25a4248a5e8d0837019b1d170146d07334abe0"
integrity sha512-BJ97wAUuU3NUiUCp44xzUFquQEvnk1wu7q4CMEUYKJWjdkr0YWYDsm4RFtAvxYsNjLsKcrFt6RvK8r+mnzMbEQ==
"@types/node@^14.14.14":
version "14.14.14"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.14.tgz#f7fd5f3cc8521301119f63910f0fb965c7d761ae"
integrity sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ==
"@types/nodemailer@*", "@types/nodemailer@^6.4.0":
version "6.4.0"
@ -2244,71 +2253,71 @@
resolved "https://registry.yarnpkg.com/@types/yamljs/-/yamljs-0.2.31.tgz#b1a620b115c96db7b3bfdf0cf54aee0c57139245"
integrity sha512-QcJ5ZczaXAqbVD3o8mw/mEBhRvO5UAdTtbvgwL/OgoWubvNBh6/MxLBAigtcgIFaq3shon9m3POIxQaLQt4fxQ==
"@types/yup@^0.29.10":
version "0.29.10"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.10.tgz#1bfa4c4a47a6f57fcc8510948757b9e47c0d6ca3"
integrity sha512-kRKRZaWkxxnOK7H5C4oWqhCw9ID1QF3cBZ2oAPoXYsjIncwgpDGigWtXGjZ91t+hsc3cvPdBci9YoJo1A96CYg==
"@types/yup@^0.29.11":
version "0.29.11"
resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e"
integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g==
"@typescript-eslint/eslint-plugin@^4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.9.1.tgz#66758cbe129b965fe9c63b04b405d0cf5280868b"
integrity sha512-QRLDSvIPeI1pz5tVuurD+cStNR4sle4avtHhxA+2uyixWGFjKzJ+EaFVRW6dA/jOgjV5DTAjOxboQkRDE8cRlQ==
"@typescript-eslint/eslint-plugin@^4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.10.0.tgz#19ed3baf4bc4232c5a7fcd32eaca75c3a5baf9f3"
integrity sha512-h6/V46o6aXpKRlarP1AiJEXuCJ7cMQdlpfMDrcllIgX3dFkLwEBTXAoNP98ZoOmqd1xvymMVRAI4e7yVvlzWEg==
dependencies:
"@typescript-eslint/experimental-utils" "4.9.1"
"@typescript-eslint/scope-manager" "4.9.1"
"@typescript-eslint/experimental-utils" "4.10.0"
"@typescript-eslint/scope-manager" "4.10.0"
debug "^4.1.1"
functional-red-black-tree "^1.0.1"
regexpp "^3.0.0"
semver "^7.3.2"
tsutils "^3.17.1"
"@typescript-eslint/experimental-utils@4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.9.1.tgz#86633e8395191d65786a808dc3df030a55267ae2"
integrity sha512-c3k/xJqk0exLFs+cWSJxIjqLYwdHCuLWhnpnikmPQD2+NGAx9KjLYlBDcSI81EArh9FDYSL6dslAUSwILeWOxg==
"@typescript-eslint/experimental-utils@4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.10.0.tgz#dbf5d0f89802d5feaf7d11e5b32df29bbc2f3a0e"
integrity sha512-opX+7ai1sdWBOIoBgpVJrH5e89ra1KoLrJTz0UtWAa4IekkKmqDosk5r6xqRaNJfCXEfteW4HXQAwMdx+jjEmw==
dependencies:
"@types/json-schema" "^7.0.3"
"@typescript-eslint/scope-manager" "4.9.1"
"@typescript-eslint/types" "4.9.1"
"@typescript-eslint/typescript-estree" "4.9.1"
"@typescript-eslint/scope-manager" "4.10.0"
"@typescript-eslint/types" "4.10.0"
"@typescript-eslint/typescript-estree" "4.10.0"
eslint-scope "^5.0.0"
eslint-utils "^2.0.0"
"@typescript-eslint/parser@^4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.9.1.tgz#2d74c4db5dd5117379a9659081a4d1ec02629055"
integrity sha512-Gv2VpqiomvQ2v4UL+dXlQcZ8zCX4eTkoIW+1aGVWT6yTO+6jbxsw7yQl2z2pPl/4B9qa5JXeIbhJpONKjXIy3g==
"@typescript-eslint/parser@^4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.10.0.tgz#1a622b0847b765b2d8f0ede6f0cdd85f03d76031"
integrity sha512-amBvUUGBMadzCW6c/qaZmfr3t9PyevcSWw7hY2FuevdZVp5QPw/K76VSQ5Sw3BxlgYCHZcK6DjIhSZK0PQNsQg==
dependencies:
"@typescript-eslint/scope-manager" "4.9.1"
"@typescript-eslint/types" "4.9.1"
"@typescript-eslint/typescript-estree" "4.9.1"
"@typescript-eslint/scope-manager" "4.10.0"
"@typescript-eslint/types" "4.10.0"
"@typescript-eslint/typescript-estree" "4.10.0"
debug "^4.1.1"
"@typescript-eslint/scope-manager@4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.9.1.tgz#cc2fde310b3f3deafe8436a924e784eaab265103"
integrity sha512-sa4L9yUfD/1sg9Kl8OxPxvpUcqxKXRjBeZxBuZSSV1v13hjfEJkn84n0An2hN8oLQ1PmEl2uA6FkI07idXeFgQ==
"@typescript-eslint/scope-manager@4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.10.0.tgz#dbd7e1fc63d7363e3aaff742a6f2b8afdbac9d27"
integrity sha512-WAPVw35P+fcnOa8DEic0tQUhoJJsgt+g6DEcz257G7vHFMwmag58EfowdVbiNcdfcV27EFR0tUBVXkDoIvfisQ==
dependencies:
"@typescript-eslint/types" "4.9.1"
"@typescript-eslint/visitor-keys" "4.9.1"
"@typescript-eslint/types" "4.10.0"
"@typescript-eslint/visitor-keys" "4.10.0"
"@typescript-eslint/types@3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727"
integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==
"@typescript-eslint/types@4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.9.1.tgz#a1a7dd80e4e5ac2c593bc458d75dd1edaf77faa2"
integrity sha512-fjkT+tXR13ks6Le7JiEdagnwEFc49IkOyys7ueWQ4O8k4quKPwPJudrwlVOJCUQhXo45PrfIvIarcrEjFTNwUA==
"@typescript-eslint/types@4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.10.0.tgz#12f983750ebad867f0c806e705c1953cd6415789"
integrity sha512-+dt5w1+Lqyd7wIPMa4XhJxUuE8+YF+vxQ6zxHyhLGHJjHiunPf0wSV8LtQwkpmAsRi1lEOoOIR30FG5S2HS33g==
"@typescript-eslint/typescript-estree@4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.9.1.tgz#6e5b86ff5a5f66809e1f347469fadeec69ac50bf"
integrity sha512-bzP8vqwX6Vgmvs81bPtCkLtM/Skh36NE6unu6tsDeU/ZFoYthlTXbBmpIrvosgiDKlWTfb2ZpPELHH89aQjeQw==
"@typescript-eslint/typescript-estree@4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.10.0.tgz#1e62e45fd57866afd42daf5e9fb6bd4e8dbcfa75"
integrity sha512-mGK0YRp9TOk6ZqZ98F++bW6X5kMTzCRROJkGXH62d2azhghmq+1LNLylkGe6uGUOQzD452NOAEth5VAF6PDo5g==
dependencies:
"@typescript-eslint/types" "4.9.1"
"@typescript-eslint/visitor-keys" "4.9.1"
"@typescript-eslint/types" "4.10.0"
"@typescript-eslint/visitor-keys" "4.10.0"
debug "^4.1.1"
globby "^11.0.1"
is-glob "^4.0.1"
@ -2337,12 +2346,12 @@
dependencies:
eslint-visitor-keys "^1.1.0"
"@typescript-eslint/visitor-keys@4.9.1":
version "4.9.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.9.1.tgz#d76374a58c4ead9e92b454d186fea63487b25ae1"
integrity sha512-9gspzc6UqLQHd7lXQS7oWs+hrYggspv/rk6zzEMhCbYwPE/sF7oxo7GAjkS35Tdlt7wguIG+ViWCPtVZHz/ybQ==
"@typescript-eslint/visitor-keys@4.10.0":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.10.0.tgz#9478822329a9bc8ebcc80623d7f79a01da5ee451"
integrity sha512-hPyz5qmDMuZWFtHZkjcCpkAKHX8vdu1G3YsCLEd25ryZgnJfj6FQuJ5/O7R+dB1ueszilJmAFMtlU4CA6se3Jg==
dependencies:
"@typescript-eslint/types" "4.9.1"
"@typescript-eslint/types" "4.10.0"
eslint-visitor-keys "^2.0.0"
"@webassemblyjs/ast@1.9.0":
@ -2942,11 +2951,6 @@ ast-types@0.13.2:
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
integrity sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA==
astral-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
astral-regex@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
@ -3075,18 +3079,18 @@ babel-plugin-react-intl@^7.0.0:
intl-messageformat-parser "^5.3.7"
schema-utils "^2.6.6"
babel-plugin-react-intl@^8.2.21:
version "8.2.21"
resolved "https://registry.yarnpkg.com/babel-plugin-react-intl/-/babel-plugin-react-intl-8.2.21.tgz#44aaf4aa59467fda661550740553abdc62f75251"
integrity sha512-FuKZt7Jv+rut8usU2AYjYQzggmx3tGnE16T5/rbXp8A1aecLs6tAgyFSqFg+9JJGEQheFot6lrQY5Lu+fq3x0g==
babel-plugin-react-intl@^8.2.22:
version "8.2.22"
resolved "https://registry.yarnpkg.com/babel-plugin-react-intl/-/babel-plugin-react-intl-8.2.22.tgz#076bbcf792a7d975803917dd49c9906799285ece"
integrity sha512-maUrmP2NY1falW5dYXGfq77YjGpQPLZUpzTgs/jRevTLTcey1u1+lTv01pA6so+iPtUfxkH3er7Z1P9zZNvKOg==
dependencies:
"@babel/core" "^7.9.0"
"@babel/helper-plugin-utils" "^7.8.3"
"@babel/types" "^7.9.5"
"@formatjs/ts-transformer" "2.12.10"
"@formatjs/ts-transformer" "2.12.11"
"@types/babel__core" "^7.1.7"
"@types/schema-utils" "^2.4.0"
intl-messageformat-parser "6.0.18"
intl-messageformat-parser "6.1.0"
schema-utils "^3.0.0"
tslib "^2.0.1"
@ -5582,23 +5586,23 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
eslint-config-prettier@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.0.0.tgz#c1ae4106f74e6c0357f44adb076771d032ac0e97"
integrity sha512-8Y8lGLVPPZdaNA7JXqnvETVC7IiVRgAP6afQu9gOQRn90YY3otMNh+x7Vr2vMePQntF+5erdSUBqSzCmU/AxaQ==
eslint-config-prettier@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.1.0.tgz#5402eb559aa94b894effd6bddfa0b1ca051c858f"
integrity sha512-9sm5/PxaFG7qNJvJzTROMM1Bk1ozXVTKI0buKOyb0Bsr1hrwi0H/TzxF/COtf1uxikIK8SwhX7K6zg78jAzbeA==
eslint-plugin-formatjs@^2.9.10:
version "2.9.10"
resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-2.9.10.tgz#dc5b80792e4166f3b2c4ca927ca47a70c89f27d2"
integrity sha512-MFkJ6ZBs70Zdyeq2JdYn950jSgSROL4x9eWlxU/AzhNvDIiHiU0oXahx02X7wdAl1vzjCC7Ro4VWiGGecQ5cpA==
eslint-plugin-formatjs@^2.9.11:
version "2.9.11"
resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-2.9.11.tgz#a44d0bc4c32f408e8091f2dcda7cfd18dda56fdc"
integrity sha512-2JY/hAqwQIyIMpJb1CANUhlZL0ZiB3uvGLrIUSSOlu8TJjGI8m57l5j+/8jDlKrAtHy9qvFp5ywR+8qi9vDvlA==
dependencies:
"@formatjs/ts-transformer" "2.12.10"
"@formatjs/ts-transformer" "2.12.11"
"@types/emoji-regex" "^8.0.0"
"@types/eslint" "^7.2.0"
"@types/estree" "^0.0.45"
"@typescript-eslint/typescript-estree" "^3.6.0"
emoji-regex "^9.0.0"
intl-messageformat-parser "6.0.18"
intl-messageformat-parser "6.1.0"
tslib "^2.0.1"
eslint-plugin-jsx-a11y@^6.4.1:
@ -5618,10 +5622,10 @@ eslint-plugin-jsx-a11y@^6.4.1:
jsx-ast-utils "^3.1.0"
language-tags "^1.0.5"
eslint-plugin-prettier@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.2.0.tgz#af391b2226fa0e15c96f36c733f6e9035dbd952c"
integrity sha512-kOUSJnFjAUFKwVxuzy6sA5yyMx6+o9ino4gCdShzBNx4eyFRudWRYKCFolKjoM40PEiuU6Cn7wBLfq3WsGg7qg==
eslint-plugin-prettier@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.0.tgz#61e295349a65688ffac0b7808ef0a8244bdd8d40"
integrity sha512-tMTwO8iUWlSRZIwS9k7/E4vrTsfvsrcM5p1eftyuqWH25nKsz/o6/54I7jwQ/3zobISyC7wMy9ZsFwgTxOcOpQ==
dependencies:
prettier-linter-helpers "^1.0.0"
@ -5680,10 +5684,10 @@ eslint-visitor-keys@^2.0.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8"
integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==
eslint@^7.15.0:
version "7.15.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.15.0.tgz#eb155fb8ed0865fcf5d903f76be2e5b6cd7e0bc7"
integrity sha512-Vr64xFDT8w30wFll643e7cGrIkPEU50yIiI36OdSIDoSGguIeaLzBo0vpGvzo9RECUqq7htURfwEtKqwytkqzA==
eslint@^7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.16.0.tgz#a761605bf9a7b32d24bb7cde59aeb0fd76f06092"
integrity sha512-iVWPS785RuDA4dWuhhgXTNrGxHHK3a8HLSMBgbbU59ruJDubUraXN8N5rn7kb8tG6sjg74eE0RA3YWT51eusEw==
dependencies:
"@babel/code-frame" "^7.0.0"
"@eslint/eslintrc" "^0.2.2"
@ -5719,7 +5723,7 @@ eslint@^7.15.0:
semver "^7.2.1"
strip-ansi "^6.0.0"
strip-json-comments "^3.1.0"
table "^5.2.3"
table "^6.0.4"
text-table "^0.2.0"
v8-compile-cache "^2.0.3"
@ -5871,10 +5875,10 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
dependencies:
homedir-polyfill "^1.0.1"
express-openapi-validator@^4.8.0:
version "4.8.0"
resolved "https://registry.yarnpkg.com/express-openapi-validator/-/express-openapi-validator-4.8.0.tgz#f45d52728bd4efabd71c43c4a8424892173ffff9"
integrity sha512-cDcMOiawm0ZCMKKltn0ySwQum6gSV8kxImc19UDu3Wu67GFDYe7qQHwmVcAISR/AxfpPkzum/LphYqZDfVRr1w==
express-openapi-validator@^4.9.4:
version "4.9.4"
resolved "https://registry.yarnpkg.com/express-openapi-validator/-/express-openapi-validator-4.9.4.tgz#2ac0b5fcbcb0c4b74a6f4df2674dbd27b1ce81d8"
integrity sha512-TAk9DcEnfwewdvou3jXLYGCgx120UMKRF/bN6vcmNPiDQPuk5axMxL9QwG5pterRkuq0LJcVQkK2dfifVnyMPA==
dependencies:
ajv "^6.12.6"
content-type "^1.0.4"
@ -6283,10 +6287,10 @@ form-data@~2.3.2:
combined-stream "^1.0.6"
mime-types "^2.1.12"
formik@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.5.tgz#addf4ed7a15ebddf22c883a3d358cd27c8a91a55"
integrity sha512-KkOsyYmh5xsow+wlbdL9QSkqvbiHSb1RIToBKiooCFW4lyypn+ZlHGjTuuOqUWBqZaI5nCEupeI275Mo6tFBzg==
formik@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.6.tgz#378a4bafe4b95caf6acf6db01f81f3fe5147559d"
integrity sha512-Kxk2zQRafy56zhLmrzcbryUpMBvT0tal5IvcifK5+4YNGelKsnrODFJ0sZQRMQboblWNym4lAW3bt+tf2vApSA==
dependencies:
deepmerge "^2.1.1"
hoist-non-react-statics "^3.3.0"
@ -7015,10 +7019,10 @@ humanize-ms@^1.2.1:
dependencies:
ms "^2.0.0"
husky@^4.3.5:
version "4.3.5"
resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.5.tgz#ab8d2a0eb6b62fef2853ee3d442c927d89290902"
integrity sha512-E5S/1HMoDDaqsH8kDF5zeKEQbYqe3wL9zJDyqyYqc8I4vHBtAoxkDBGXox0lZ9RI+k5GyB728vZdmnM4bYap+g==
husky@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/husky/-/husky-4.3.6.tgz#ebd9dd8b9324aa851f1587318db4cccb7665a13c"
integrity sha512-o6UjVI8xtlWRL5395iWq9LKDyp/9TE7XMOTvIpEVzW638UcGxTmV5cfel6fsk/jbZSTlvfGVJf2svFtybcIZag==
dependencies:
chalk "^4.0.0"
ci-info "^2.0.0"
@ -7238,6 +7242,14 @@ intl-messageformat-parser@6.0.18:
"@formatjs/ecma402-abstract" "1.5.0"
tslib "^2.0.1"
intl-messageformat-parser@6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.1.0.tgz#134328151c41592d9e1a61f5c6779c06c8eb3f08"
integrity sha512-nPPh2kOrKqlh4D9bCAetxkrUiq5/6S1exPQyg52Ihusy0ECNGhZ0Qmq8pFRK9gWIuiQPVmLA7eSNp8diC2tX3w==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
tslib "^2.0.1"
intl-messageformat-parser@^5.3.7:
version "5.5.1"
resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-5.5.1.tgz#f09a692755813e6220081e3374df3fb1698bd0c6"
@ -7245,13 +7257,13 @@ intl-messageformat-parser@^5.3.7:
dependencies:
"@formatjs/intl-numberformat" "^5.5.2"
intl-messageformat@9.3.20:
version "9.3.20"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.3.20.tgz#87ec7e5f7a0f5d13157dc8bed88fe37b4c57b2a1"
integrity sha512-jmpjYHE076J/0CIofrPhtUC4LfmsAhuv4JMQxytl2KJd2bim+3+gQJh+Z1vyHUzcj4fIHdt388ZGchb8f0NwOA==
intl-messageformat@9.4.0:
version "9.4.0"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.4.0.tgz#b9c9c00a6e88a8f1ffd9ee3e54340c9dfb765d13"
integrity sha512-zcF8OWG52dCwwePkykqqv7F038vCaixPR14Lr3YUFc9jRdGoCazl2dTE3BwBaeHr3pG/qYb6A/mwMKrj4LFt9Q==
dependencies:
fast-memoize "^2.5.2"
intl-messageformat-parser "6.0.18"
intl-messageformat-parser "6.1.0"
tslib "^2.0.1"
intl@^1.2.5:
@ -9396,6 +9408,11 @@ nodemailer@6.4.16, nodemailer@^6.4.16:
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.16.tgz#5cb6391b1d79ab7eff32d6f9f48366b5a7117293"
integrity sha512-68K0LgZ6hmZ7PVmwL78gzNdjpj5viqBdFqKrTtr9bZbJYj6BRj5W6WGkxXrEnUl3Co3CBXi3CZBUlpV/foGnOQ==
nodemailer@^6.4.17:
version "6.4.17"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.4.17.tgz#8de98618028953b80680775770f937243a7d7877"
integrity sha512-89ps+SBGpo0D4Bi5ZrxcrCiRFaMmkCt+gItMXQGzEtZVR3uAD3QAQIDoxTWnx3ky0Dwwy/dhFrQ+6NNGXpw/qQ==
nodemon@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d"
@ -11361,21 +11378,21 @@ react-intersection-observer@^8.31.0:
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-8.31.0.tgz#0ed21aaf93c4c0475b22b0ccaba6169076d01605"
integrity sha512-XraIC/tkrD9JtrmVA7ypEN1QIpKc52mXBH1u/bz/aicRLo8QQEJQAMUTb8mz4B6dqpPwyzgjrr7Ljv/2ACDtqw==
react-intl@^5.10.6:
version "5.10.6"
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.10.6.tgz#064dd69f3e96434f9145cac0b21c5a47f3ac6088"
integrity sha512-IWhPTGGggs/n/OKkhEHAZ7rCfQ8m/2hmYIwJtOPuNQVyKKU+R863q4xP/+uCW1NOXB+yvbF2p7CB/v2hkuEVCA==
react-intl@^5.10.9:
version "5.10.9"
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.10.9.tgz#cab8d9445059d3544cffc762c3e6af47ef9bc8ad"
integrity sha512-DfUF4YMlZqaNRfgfvf46AcXxz7pDi7pkxRbQoimUJWEkjep+6QYLlH7ogypysGD1Sl5kbWi7b69bbG7wPqt1vA==
dependencies:
"@formatjs/ecma402-abstract" "1.5.0"
"@formatjs/intl" "1.4.10"
"@formatjs/intl-displaynames" "4.0.1"
"@formatjs/intl-listformat" "5.0.1"
"@formatjs/intl-relativetimeformat" "8.0.0"
"@formatjs/intl" "1.4.13"
"@formatjs/intl-displaynames" "4.0.2"
"@formatjs/intl-listformat" "5.0.2"
"@formatjs/intl-relativetimeformat" "8.0.1"
"@types/hoist-non-react-statics" "^3.3.1"
fast-memoize "^2.5.2"
hoist-non-react-statics "^3.3.2"
intl-messageformat "9.3.20"
intl-messageformat-parser "6.0.18"
intl-messageformat "9.4.0"
intl-messageformat-parser "6.1.0"
shallow-equal "^1.2.1"
tslib "^2.0.1"
@ -12373,15 +12390,6 @@ slash@^3.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
slice-ansi@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==
dependencies:
ansi-styles "^3.2.0"
astral-regex "^1.0.0"
is-fullwidth-code-point "^2.0.0"
slice-ansi@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787"
@ -13029,22 +13037,22 @@ swagger-ui-express@^4.1.5:
dependencies:
swagger-ui-dist "^3.18.1"
swr@^0.3.9:
version "0.3.9"
resolved "https://registry.yarnpkg.com/swr/-/swr-0.3.9.tgz#a179a795244c7b68684af6a632f1ad579e6a69e0"
integrity sha512-lyN4SjBzpoW4+v3ebT7JUtpzf9XyzrFwXIFv+E8ZblvMa5enSNaUBs4EPkL8gGA/GDMLngEmB53o5LaNboAPfg==
swr@^0.3.11:
version "0.3.11"
resolved "https://registry.yarnpkg.com/swr/-/swr-0.3.11.tgz#f7f50ed26c06afea4249482cec504768a2272664"
integrity sha512-ya30LuRGK2R7eDlttnb7tU5EmJYJ+N6ytIOM2j0Hqs0qauJcDjVLDOGy7KmFeH5ivOwLHalFaIyYl2K+SGa7HQ==
dependencies:
dequal "2.0.2"
table@^5.2.3:
version "5.4.6"
resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==
table@^6.0.4:
version "6.0.4"
resolved "https://registry.yarnpkg.com/table/-/table-6.0.4.tgz#c523dd182177e926c723eb20e1b341238188aa0d"
integrity sha512-sBT4xRLdALd+NFBvwOz8bw4b15htyythha+q+DVZqy2RS08PPC8O2sZFgJYEY7bJvbCFKccs+WIZ/cd+xxTWCw==
dependencies:
ajv "^6.10.2"
lodash "^4.17.14"
slice-ansi "^2.1.0"
string-width "^3.0.0"
ajv "^6.12.4"
lodash "^4.17.20"
slice-ansi "^4.0.0"
string-width "^4.2.0"
"tailwindcss@npm:@tailwindcss/postcss7-compat":
version "2.0.1"
@ -13548,10 +13556,10 @@ typescript@^4.0:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2"
integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==
typescript@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9"
integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==
typescript@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7"
integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==
uc.micro@^1.0.1:
version "1.0.6"

Loading…
Cancel
Save