mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Merge branch 'develop'
This commit is contained in:
@@ -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
|
||||
|
||||
34
package.json
34
package.json
@@ -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: '',
|
||||
|
||||
52
server/migration/1608477467935-AddLastSeasonChangeMedia.ts
Normal file
52
server/migration/1608477467935-AddLastSeasonChangeMedia.ts
Normal file
@@ -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;
|
||||
|
||||
96
server/templates/email/test-email/html.pug
Normal file
96
server/templates/email/test-email/html.pug
Normal file
@@ -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&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.
|
||||
1
server/templates/email/test-email/subject.pug
Normal file
1
server/templates/email/test-email/subject.pug
Normal file
@@ -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}
|
||||
|
||||
66
src/components/MovieDetails/MovieCrew/index.tsx
Normal file
66
src/components/MovieDetails/MovieCrew/index.tsx
Normal file
@@ -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>
|
||||
</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>
|
||||
</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"
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
<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" />
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(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>
|
||||
<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"
|
||||
|
||||
66
src/components/TvDetails/TvCrew/index.tsx
Normal file
66
src/components/TvDetails/TvCrew/index.tsx
Normal file
@@ -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",
|
||||
|
||||
9
src/pages/movie/[movieId]/crew.tsx
Normal file
9
src/pages/movie/[movieId]/crew.tsx
Normal file
@@ -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;
|
||||
9
src/pages/tv/[tvId]/crew.tsx
Normal file
9
src/pages/tv/[tvId]/crew.tsx
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
24
src/utils/creditHelpers.ts
Normal file
24
src/utils/creditHelpers.ts
Normal file
@@ -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],
|
||||
},
|
||||
|
||||
332
yarn.lock
332
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user