From 85aec4f8925746ebae9bcc99d8480b78ccfd851e Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 24 Oct 2021 00:50:36 +0200 Subject: [PATCH 01/53] fix(lang): translations update from Weblate (#2212) * feat(lang): translated using Weblate (Japanese) Currently translated at 56.8% (506 of 890 strings) Co-authored-by: TheCatLady Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ja/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Russian) Currently translated at 99.7% (888 of 890 strings) Co-authored-by: Hosted Weblate Co-authored-by: Sergey Moiseev Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/ru/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Chinese (Simplified)) Currently translated at 98.9% (881 of 890 strings) Co-authored-by: Hosted Weblate Co-authored-by: TheCatLady Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hans/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (890 of 890 strings) feat(lang): translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (890 of 890 strings) Co-authored-by: Hosted Weblate Co-authored-by: TheCatLady Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/zh_Hant/ Translation: Overseerr/Overseerr Frontend Co-authored-by: TheCatLady Co-authored-by: Sergey Moiseev --- src/i18n/locale/ja.json | 4 ++-- src/i18n/locale/ru.json | 2 +- src/i18n/locale/zh_Hans.json | 2 +- src/i18n/locale/zh_Hant.json | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/i18n/locale/ja.json b/src/i18n/locale/ja.json index fd4713dec..16c558857 100644 --- a/src/i18n/locale/ja.json +++ b/src/i18n/locale/ja.json @@ -17,7 +17,7 @@ "components.MovieDetails.budget": "予算", "components.MovieDetails.cast": "出演者", "components.MovieDetails.manageModalClearMedia": "メディアのデータを消去", - "components.MovieDetails.manageModalClearMediaWarning": "*リクエストを含め、すべての詳細情報が消去されます。この操作は元に戻すことができません。この作品が Plex ライブラリに存在する場合、詳細情報は次のスキャンで再作成されます。", + "components.MovieDetails.manageModalClearMediaWarning": "※リクエストを含め、すべての詳細情報が消去されます。この操作は元に戻すことができません。この作品が Plex ライブラリに存在する場合、詳細情報は次のスキャンで再作成されます。", "components.MovieDetails.manageModalNoRequests": "リクエストがありません。", "components.MovieDetails.manageModalRequests": "リクエスト", "components.MovieDetails.manageModalTitle": "映画を管理", @@ -149,7 +149,7 @@ "components.Setup.welcome": "Overseerr へようこそ", "components.TvDetails.cast": "出演者", "components.TvDetails.manageModalClearMedia": "メディアのデータを消去", - "components.TvDetails.manageModalClearMediaWarning": "*リクエストを含め、メーディアのデータをすべて消去されます。この操作は元に戻すことができません。このアイテムが Plex ライブラリに存在する場合、メディア情報は次の同期で再作成されます。", + "components.TvDetails.manageModalClearMediaWarning": "※リクエストを含め、メーディアのデータをすべて消去されます。この操作は元に戻すことができません。このアイテムが Plex ライブラリに存在する場合、メディア情報は次の同期で再作成されます。", "components.TvDetails.manageModalNoRequests": "リクエストがありません。", "components.TvDetails.manageModalRequests": "リクエスト", "components.TvDetails.manageModalTitle": "シリーズの管理", diff --git a/src/i18n/locale/ru.json b/src/i18n/locale/ru.json index 842bb5f6b..ff790660c 100644 --- a/src/i18n/locale/ru.json +++ b/src/i18n/locale/ru.json @@ -551,7 +551,7 @@ "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Настройки уведомлений Pushover успешно сохранены!", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Не удалось сохранить настройки уведомлений Pushover.", "components.Settings.Notifications.NotificationsPushover.accessTokenTip": "Зарегистрируйте приложение для использования с Overseerr", - "i18n.view": "Вид", + "i18n.view": "Посмотреть", "i18n.notrequested": "Не запрошено", "i18n.noresults": "Результатов нет.", "i18n.delimitedlist": "{a}, {b}", diff --git a/src/i18n/locale/zh_Hans.json b/src/i18n/locale/zh_Hans.json index a8b956c13..55899e73c 100644 --- a/src/i18n/locale/zh_Hans.json +++ b/src/i18n/locale/zh_Hans.json @@ -77,7 +77,7 @@ "components.StatusChacker.reloadOverseerr": "刷新页面", "components.StatusChacker.newversionavailable": "软件更新", "components.StatusChacker.newversionDescription": "Overseerr 软件已更新。请点击以下的按钮刷新页面。", - "components.StatusBadge.status4k": "4K 版 {status}", + "components.StatusBadge.status4k": "4K 版{status}", "components.Setup.welcome": "欢迎來到 Overseerr", "components.Setup.tip": "提示", "components.Setup.signinMessage": "首先,请使用您的 Plex 账户登入", diff --git a/src/i18n/locale/zh_Hant.json b/src/i18n/locale/zh_Hant.json index ad1d2784c..ab9dc42b3 100644 --- a/src/i18n/locale/zh_Hant.json +++ b/src/i18n/locale/zh_Hant.json @@ -21,7 +21,7 @@ "i18n.partiallyavailable": "部分可觀看", "i18n.unavailable": "不可觀看", "components.StatusChacker.newversionavailable": "軟體更新", - "components.StatusBadge.status4k": "4K 版 {status}", + "components.StatusBadge.status4k": "4K 版{status}", "components.Setup.tip": "提示", "components.Setup.welcome": "歡迎來到 Overseerr", "components.TvDetails.TvCast.fullseriescast": "演員陣容", @@ -322,7 +322,7 @@ "components.StatusChacker.newversionDescription": "Overseerr 軟體已更新。請點擊以下的按鈕刷新頁面。", "components.RequestModal.requestcancelled": "{title} 的請求已被取消。", "components.RequestModal.AdvancedRequester.qualityprofile": "品質設定", - "components.RequestModal.AdvancedRequester.animenote": "*這是個動漫節目。", + "components.RequestModal.AdvancedRequester.animenote": "※這是個動漫節目。", "components.RequestModal.AdvancedRequester.advancedoptions": "進階選項", "components.RequestModal.AdvancedRequester.default": "{name}(預設)", "components.RequestModal.AdvancedRequester.destinationserver": "目標伺服器", @@ -416,9 +416,9 @@ "components.Settings.toastPlexConnectingFailure": "Plex 伺服器連線失敗。", "components.TvDetails.mark4kavailable": "標記 4K 版為可觀看", "components.TvDetails.markavailable": "標記為可觀看", - "components.TvDetails.manageModalClearMediaWarning": "*這將會刪除包括使用者請求在內所有有關這個電視節目的資料。如果這個節目存在於您的 Plex 伺服器,資料將會在媒體庫掃描時重新建立。", - "components.MovieDetails.manageModalClearMediaWarning": "*這將會刪除包括使用者請求在內所有有關這部電影的資料。如果這部電影存在於您的 Plex 伺服器,資料將會在媒體庫掃描時重新建立。", - "components.TvDetails.allseasonsmarkedavailable": "*每季將被標記為可觀看。", + "components.TvDetails.manageModalClearMediaWarning": "※這將會刪除包括使用者請求在內所有有關這個電視節目的資料。如果這個節目存在於您的 Plex 伺服器,資料將會在媒體庫掃描時重新建立。", + "components.MovieDetails.manageModalClearMediaWarning": "※這將會刪除包括使用者請求在內所有有關這部電影的資料。如果這部電影存在於您的 Plex 伺服器,資料將會在媒體庫掃描時重新建立。", + "components.TvDetails.allseasonsmarkedavailable": "※每季將被標記為可觀看。", "components.Settings.csrfProtectionHoverTip": "除非您了解此功能,請勿啟用它!", "components.UserList.users": "使用者", "components.Settings.applicationTitle": "應用程式名", From dca363809dbf3d08eca3bce0d1bee4f77b5fa0ac Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 24 Oct 2021 07:58:40 -0400 Subject: [PATCH 02/53] docs: add Overseerr-Extension to 3rd-party integrations (#2227) [skip ci] --- docs/extending-overseerr/third-party.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md index cf2946d91..df82fa239 100644 --- a/docs/extending-overseerr/third-party.md +++ b/docs/extending-overseerr/third-party.md @@ -10,5 +10,6 @@ We do not officially support these third-party integrations. If you run into any - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot - [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component +- [Overseerr-Extension](https://github.com/RemiRigal/Overseerr-Extension), a browser extension TMDb and IMDb - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter From 6565c7dd9b7023bb494126290a339a98a69e2042 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 24 Oct 2021 08:35:36 -0400 Subject: [PATCH 03/53] docs: add FAQ entry about mobile apps (#2228) [skip ci] * docs: grammar correction * docs: add FAQ about mobile apps * docs: remove Overseerr-Extension for now --- docs/extending-overseerr/third-party.md | 1 - docs/support/faq.md | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/extending-overseerr/third-party.md b/docs/extending-overseerr/third-party.md index df82fa239..cf2946d91 100644 --- a/docs/extending-overseerr/third-party.md +++ b/docs/extending-overseerr/third-party.md @@ -10,6 +10,5 @@ We do not officially support these third-party integrations. If you run into any - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot - [Doplarr](https://github.com/kiranshila/Doplarr), a Discord request bot - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component -- [Overseerr-Extension](https://github.com/RemiRigal/Overseerr-Extension), a browser extension TMDb and IMDb - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter diff --git a/docs/support/faq.md b/docs/support/faq.md index 19d71e931..d3ea3cdce 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -20,6 +20,12 @@ A more advanced, user-friendly, and secure (if using SSL) method is to set up a The most secure method (but also the most inconvenient method) is to set up a VPN tunnel to your home server. You would then be able to access Overseerr as if you were on your local network, via `http://LOCAL-IP-ADDRESS:5055`. +### Are there mobile apps for Overseerr? + +Since Overseerr has an almost native app experience when installed as a Progressive Web App (PWA), there are no plans to develop mobile apps for Overseerr. + +Out of the box, Overseerr already fulfills most of the [PWA install criteria](https://web.dev/install-criteria/). You simply need to make sure that your Overseerr instance is being served over HTTPS (e.g., via a [reverse proxy](../extending-overseerr/reverse-proxy.md)). + ### Overseerr is amazing! But it is not translated in my language yet! Can I help with translations? You sure can! We are using [Weblate](https://hosted.weblate.org/engage/overseerr/) for translations. If your language is not listed, please [open a feature request on GitHub](https://github.com/sct/overseerr/issues/new/choose). From e402c42aaa7d795cd724856a2e23615bb1a3695d Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Sun, 24 Oct 2021 21:44:20 +0900 Subject: [PATCH 04/53] feat: issues (#2180) --- overseerr-api.yml | 274 ++++++ package.json | 4 + server/constants/issue.ts | 18 + server/entity/Issue.ts | 68 ++ server/entity/IssueComment.ts | 42 + server/entity/Media.ts | 6 +- server/entity/User.ts | 4 + server/interfaces/api/issueInterfaces.ts | 6 + server/lib/notifications/agents/agent.ts | 4 +- server/lib/notifications/agents/discord.ts | 54 ++ server/lib/notifications/agents/webpush.ts | 10 +- server/lib/notifications/index.ts | 3 + server/lib/permissions.ts | 3 + server/migration/1634904083966-AddIssues.ts | 55 ++ server/routes/index.ts | 4 + server/routes/issue.ts | 325 +++++++ server/routes/issueComment.ts | 132 +++ server/routes/request.ts | 228 ++--- server/subscriber/IssueCommentSubscriber.ts | 65 ++ server/subscriber/IssueSubscriber.ts | 50 + server/subscriber/MediaSubscriber.ts | 6 +- src/components/CollectionDetails/index.tsx | 4 +- src/components/Common/SlideOver/index.tsx | 2 +- src/components/IssueBlock/index.tsx | 68 ++ .../IssueDetails/IssueComment/index.tsx | 263 +++++ .../IssueDetails/IssueDescription/index.tsx | 152 +++ src/components/IssueDetails/index.tsx | 600 ++++++++++++ src/components/IssueList/IssueItem/index.tsx | 257 +++++ src/components/IssueList/index.tsx | 256 +++++ .../IssueModal/CreateIssueModal/index.tsx | 303 ++++++ src/components/IssueModal/constants.ts | 34 + src/components/IssueModal/index.tsx | 36 + src/components/Layout/Sidebar/index.tsx | 25 +- src/components/ManageSlideOver/index.tsx | 271 ++++++ src/components/MovieDetails/index.tsx | 214 +--- .../NotificationTypeSelector/index.tsx | 43 + src/components/PermissionEdit/index.tsx | 26 + .../RequestList/RequestItem/index.tsx | 2 +- src/components/TvDetails/index.tsx | 224 +---- src/i18n/globalMessages.ts | 2 + src/i18n/locale/en.json | 125 ++- src/pages/issues/[issueId]/index.tsx | 9 + src/pages/issues/index.tsx | 9 + src/styles/globals.css | 910 +++++++++--------- stylelint.config.js | 1 + 45 files changed, 4260 insertions(+), 937 deletions(-) create mode 100644 server/constants/issue.ts create mode 100644 server/entity/Issue.ts create mode 100644 server/entity/IssueComment.ts create mode 100644 server/interfaces/api/issueInterfaces.ts create mode 100644 server/migration/1634904083966-AddIssues.ts create mode 100644 server/routes/issue.ts create mode 100644 server/routes/issueComment.ts create mode 100644 server/subscriber/IssueCommentSubscriber.ts create mode 100644 server/subscriber/IssueSubscriber.ts create mode 100644 src/components/IssueBlock/index.tsx create mode 100644 src/components/IssueDetails/IssueComment/index.tsx create mode 100644 src/components/IssueDetails/IssueDescription/index.tsx create mode 100644 src/components/IssueDetails/index.tsx create mode 100644 src/components/IssueList/IssueItem/index.tsx create mode 100644 src/components/IssueList/index.tsx create mode 100644 src/components/IssueModal/CreateIssueModal/index.tsx create mode 100644 src/components/IssueModal/constants.ts create mode 100644 src/components/IssueModal/index.tsx create mode 100644 src/components/ManageSlideOver/index.tsx create mode 100644 src/pages/issues/[issueId]/index.tsx create mode 100644 src/pages/issues/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index bf324b029..87d8061ea 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1687,6 +1687,36 @@ components: type: number name: type: string + Issue: + type: object + properties: + id: + type: number + example: 1 + issueType: + type: number + example: 1 + media: + $ref: '#/components/schemas/MediaInfo' + createdBy: + $ref: '#/components/schemas/User' + modifiedBy: + $ref: '#/components/schemas/User' + comments: + type: array + items: + $ref: '#/components/schemas/IssueComment' + IssueComment: + type: object + properties: + id: + type: number + example: 1 + user: + $ref: '#/components/schemas/User' + message: + type: string + example: A comment securitySchemes: cookieAuth: type: apiKey @@ -5183,7 +5213,251 @@ paths: type: array items: type: string + /issue: + get: + summary: Get all issues + description: | + Returns a list of issues in JSON format. + tags: + - issue + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 20 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: filter + schema: + type: string + enum: [all, open, resolved] + default: open + - in: query + name: requestedBy + schema: + type: number + nullable: true + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/Issue' + post: + summary: Create new issue + description: | + Creates a new issue + tags: + - issue + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + issueType: + type: number + message: + type: string + mediaId: + type: number + responses: + '201': + description: Succesfully created the issue + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /issue/{issueId}: + get: + summary: Get issue + description: | + Returns a single issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: number + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + delete: + summary: Delete issue + description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue + /issue/{issueId}/comment: + post: + summary: Create a comment + description: | + Creates a comment and returns associated issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: number + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + responses: + '200': + description: Issue returned with new comment + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /issueComment/{commentId}: + get: + summary: Get issue comment + description: | + Returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + responses: + '200': + description: Comment returned + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + put: + summary: Update issue comment + description: | + Updates and returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + '200': + description: Comment updated + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + delete: + summary: Delete issue comment + description: | + Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action. + tags: + - issue + parameters: + - in: path + name: commentId + description: Issue Comment ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue comment + /issue/{issueId}/{status}: + post: + summary: Update an issue's status + description: | + Updates an issue's status to approved or declined. Also returns the issue in a JSON object. + Requires the `MANAGE_ISSUES` permission or `ADMIN`. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [open, resolved] + responses: + '200': + description: Issue status changed + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' security: - cookieAuth: [] - apiKey: [] diff --git a/package.json b/package.json index 0b1ca3623..3f0d86545 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,10 @@ "migration:run": "ts-node --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run", "format": "prettier --write ." }, + "repository": { + "type": "git", + "url": "https://github.com/sct/overseerr.git" + }, "license": "MIT", "dependencies": { "@headlessui/react": "^1.4.1", diff --git a/server/constants/issue.ts b/server/constants/issue.ts new file mode 100644 index 000000000..859118538 --- /dev/null +++ b/server/constants/issue.ts @@ -0,0 +1,18 @@ +export enum IssueType { + VIDEO = 1, + AUDIO = 2, + SUBTITLES = 3, + OTHER = 4, +} + +export enum IssueStatus { + OPEN = 1, + RESOLVED = 2, +} + +export const IssueTypeNames = { + [IssueType.AUDIO]: 'Audio', + [IssueType.VIDEO]: 'Video', + [IssueType.SUBTITLES]: 'Subtitles', + [IssueType.OTHER]: 'Other', +}; diff --git a/server/entity/Issue.ts b/server/entity/Issue.ts new file mode 100644 index 000000000..d8e05c565 --- /dev/null +++ b/server/entity/Issue.ts @@ -0,0 +1,68 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IssueStatus, IssueType } from '../constants/issue'; +import IssueComment from './IssueComment'; +import Media from './Media'; +import { User } from './User'; + +@Entity() +class Issue { + @PrimaryGeneratedColumn() + public id: number; + + @Column({ type: 'int' }) + public issueType: IssueType; + + @Column({ type: 'int', default: IssueStatus.OPEN }) + public status: IssueStatus; + + @Column({ type: 'int', default: 0 }) + public problemSeason: number; + + @Column({ type: 'int', default: 0 }) + public problemEpisode: number; + + @ManyToOne(() => Media, (media) => media.issues, { + eager: true, + onDelete: 'CASCADE', + }) + public media: Media; + + @ManyToOne(() => User, (user) => user.createdIssues, { + eager: true, + onDelete: 'CASCADE', + }) + public createdBy: User; + + @ManyToOne(() => User, { + eager: true, + onDelete: 'CASCADE', + nullable: true, + }) + public modifiedBy?: User; + + @OneToMany(() => IssueComment, (comment) => comment.issue, { + cascade: true, + eager: true, + }) + public comments: IssueComment[]; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default Issue; diff --git a/server/entity/IssueComment.ts b/server/entity/IssueComment.ts new file mode 100644 index 000000000..e45216392 --- /dev/null +++ b/server/entity/IssueComment.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import Issue from './Issue'; +import { User } from './User'; + +@Entity() +class IssueComment { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => User, { + eager: true, + onDelete: 'CASCADE', + }) + public user: User; + + @ManyToOne(() => Issue, (issue) => issue.comments, { + onDelete: 'CASCADE', + }) + public issue: Issue; + + @Column({ type: 'text' }) + public message: string; + + @CreateDateColumn() + public createdAt: Date; + + @UpdateDateColumn() + public updatedAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} + +export default IssueComment; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 9666ac289..9cb8cd793 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -16,6 +16,7 @@ import { MediaStatus, MediaType } from '../constants/media'; import downloadTracker, { DownloadingItem } from '../lib/downloadtracker'; import { getSettings } from '../lib/settings'; import logger from '../logger'; +import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import Season from './Season'; @@ -54,7 +55,7 @@ class Media { try { const media = await mediaRepository.findOne({ where: { tmdbId: id, mediaType }, - relations: ['requests'], + relations: ['requests', 'issues'], }); return media; @@ -97,6 +98,9 @@ class Media { }) public seasons: Season[]; + @OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) + public issues: Issue[]; + @CreateDateColumn() public createdAt: Date; diff --git a/server/entity/User.ts b/server/entity/User.ts index 77f0e8b11..d54e31ae5 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -27,6 +27,7 @@ import { } from '../lib/permissions'; import { getSettings } from '../lib/settings'; import logger from '../logger'; +import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; import { UserPushSubscription } from './UserPushSubscription'; @@ -115,6 +116,9 @@ export class User { @OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user) public pushSubscriptions: UserPushSubscription[]; + @OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true }) + public createdIssues: Issue[]; + @CreateDateColumn() public createdAt: Date; diff --git a/server/interfaces/api/issueInterfaces.ts b/server/interfaces/api/issueInterfaces.ts new file mode 100644 index 000000000..bd17f1958 --- /dev/null +++ b/server/interfaces/api/issueInterfaces.ts @@ -0,0 +1,6 @@ +import Issue from '../../entity/Issue'; +import { PaginatedResponse } from './common'; + +export interface IssueResultsResponse extends PaginatedResponse { + results: Issue[]; +} diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 66c52a16e..3de828f9c 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -1,5 +1,6 @@ import { Notification } from '..'; -import Media from '../../../entity/Media'; +import type Issue from '../../../entity/Issue'; +import type Media from '../../../entity/Media'; import { MediaRequest } from '../../../entity/MediaRequest'; import { User } from '../../../entity/User'; import { NotificationAgentConfig } from '../../settings'; @@ -12,6 +13,7 @@ export interface NotificationPayload { message?: string; extra?: { name: string; value: string }[]; request?: MediaRequest; + issue?: Issue; } export abstract class BaseAgent { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 97be2cba5..8e08e9834 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -1,6 +1,8 @@ import axios from 'axios'; import { getRepository } from 'typeorm'; import { hasNotificationType, Notification } from '..'; +import { IssueStatus, IssueTypeNames } from '../../../constants/issue'; +import { MediaType } from '../../../constants/media'; import { User } from '../../../entity/User'; import logger from '../../../logger'; import { Permission } from '../../permissions'; @@ -120,6 +122,48 @@ class DiscordAgent }); } + // If payload has an issue attached, push issue specific fields + if (payload.issue) { + fields.push( + { + name: 'Created By', + value: payload.issue.createdBy.displayName, + inline: true, + }, + { + name: 'Issue Type', + value: IssueTypeNames[payload.issue.issueType], + inline: true, + }, + { + name: 'Issue Status', + value: + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved', + inline: true, + } + ); + + if (payload.issue.media.mediaType === MediaType.TV) { + fields.push({ + name: 'Affected Season', + value: + payload.issue.problemSeason > 0 + ? `Season ${payload.issue.problemSeason}` + : 'All Seasons', + }); + + if (payload.issue.problemSeason > 0) { + fields.push({ + name: 'Affected Episode', + value: + payload.issue.problemEpisode > 0 + ? `Episode ${payload.issue.problemEpisode}` + : 'All Episodes', + }); + } + } + } + switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; @@ -161,6 +205,16 @@ class DiscordAgent value: 'Failed', inline: true, }); + break; + case Notification.ISSUE_CREATED: + case Notification.ISSUE_COMMENT: + case Notification.ISSUE_RESOLVED: + color = EmbedColors.ORANGE; + + if (payload.issue && payload.issue.status === IssueStatus.RESOLVED) { + color = EmbedColors.GREEN; + } + break; } diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index afe4b7c10..624dab223 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -43,11 +43,6 @@ class WebPushAgent payload: NotificationPayload ): PushNotificationPayload { switch (type) { - case Notification.NONE: - return { - notificationType: Notification[type], - subject: 'Unknown', - }; case Notification.TEST_NOTIFICATION: return { notificationType: Notification[type], @@ -132,6 +127,11 @@ class WebPushAgent requestId: payload.request?.id, actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, }; + default: + return { + notificationType: Notification[type], + subject: 'Unknown', + }; } } diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index a2eb01419..8769360fd 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -10,6 +10,9 @@ export enum Notification { TEST_NOTIFICATION = 32, MEDIA_DECLINED = 64, MEDIA_AUTO_APPROVED = 128, + ISSUE_CREATED = 256, + ISSUE_COMMENT = 512, + ISSUE_RESOLVED = 1024, } export const hasNotificationType = ( diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index fbf36e6b8..95160d380 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -19,6 +19,9 @@ export enum Permission { AUTO_APPROVE_4K_TV = 131072, REQUEST_MOVIE = 262144, REQUEST_TV = 524288, + MANAGE_ISSUES = 1048576, + VIEW_ISSUES = 2097152, + CREATE_ISSUES = 4194304, } export interface PermissionCheckOptions { diff --git a/server/migration/1634904083966-AddIssues.ts b/server/migration/1634904083966-AddIssues.ts new file mode 100644 index 000000000..0c6116f9d --- /dev/null +++ b/server/migration/1634904083966-AddIssues.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIssues1634904083966 implements MigrationInterface { + name = 'AddIssues1634904083966'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)` + ); + await queryRunner.query( + `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)` + ); + await queryRunner.query( + `CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"` + ); + await queryRunner.query(`DROP TABLE "issue"`); + await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`); + await queryRunner.query( + `CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"` + ); + await queryRunner.query(`DROP TABLE "issue_comment"`); + await queryRunner.query( + `ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"` + ); + await queryRunner.query( + `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)` + ); + await queryRunner.query( + `INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"` + ); + await queryRunner.query(`DROP TABLE "temporary_issue_comment"`); + await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`); + await queryRunner.query( + `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)` + ); + await queryRunner.query( + `INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"` + ); + await queryRunner.query(`DROP TABLE "temporary_issue"`); + await queryRunner.query(`DROP TABLE "issue_comment"`); + await queryRunner.query(`DROP TABLE "issue"`); + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 25386e05b..3f57e8154 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -14,6 +14,8 @@ import { isPerson } from '../utils/typeHelpers'; import authRoutes from './auth'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; +import issueRoutes from './issue'; +import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; import movieRoutes from './movie'; import personRoutes from './person'; @@ -108,6 +110,8 @@ router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); +router.use('/issue', isAuthenticated(), issueRoutes); +router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); router.get('/regions', isAuthenticated(), async (req, res) => { diff --git a/server/routes/issue.ts b/server/routes/issue.ts new file mode 100644 index 000000000..d2774208d --- /dev/null +++ b/server/routes/issue.ts @@ -0,0 +1,325 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import { IssueStatus } from '../constants/issue'; +import Issue from '../entity/Issue'; +import IssueComment from '../entity/IssueComment'; +import Media from '../entity/Media'; +import { IssueResultsResponse } from '../interfaces/api/issueInterfaces'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; +import { isAuthenticated } from '../middleware/auth'; + +const issueRoutes = Router(); + +issueRoutes.get, IssueResultsResponse>( + '/', + isAuthenticated( + [ + Permission.MANAGE_ISSUES, + Permission.VIEW_ISSUES, + Permission.CREATE_ISSUES, + ], + { type: 'or' } + ), + async (req, res, next) => { + const pageSize = req.query.take ? Number(req.query.take) : 10; + const skip = req.query.skip ? Number(req.query.skip) : 0; + const createdBy = req.query.createdBy ? Number(req.query.createdBy) : null; + + let sortFilter: string; + + switch (req.query.sort) { + case 'modified': + sortFilter = 'issue.updatedAt'; + break; + default: + sortFilter = 'issue.createdAt'; + } + + let statusFilter: IssueStatus[]; + + switch (req.query.filter) { + case 'open': + statusFilter = [IssueStatus.OPEN]; + break; + case 'resolved': + statusFilter = [IssueStatus.RESOLVED]; + break; + default: + statusFilter = [IssueStatus.OPEN, IssueStatus.RESOLVED]; + } + + let query = getRepository(Issue) + .createQueryBuilder('issue') + .leftJoinAndSelect('issue.createdBy', 'createdBy') + .leftJoinAndSelect('issue.media', 'media') + .leftJoinAndSelect('issue.modifiedBy', 'modifiedBy') + .where('issue.status IN (:...issueStatus)', { + issueStatus: statusFilter, + }); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], + { type: 'or' } + ) + ) { + if (createdBy && createdBy !== req.user?.id) { + return next({ + status: 403, + message: + 'You do not have permission to view issues created by other users', + }); + } + query = query.andWhere('createdBy.id = :id', { id: req.user?.id }); + } else if (createdBy) { + query = query.andWhere('createdBy.id = :id', { id: createdBy }); + } + + const [issues, issueCount] = await query + .orderBy(sortFilter, 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(issueCount / pageSize), + pageSize, + results: issueCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: issues, + }); + } +); + +issueRoutes.post< + Record, + Issue, + { + message: string; + mediaId: number; + issueType: number; + problemSeason: number; + problemEpisode: number; + } +>( + '/', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + const issueRepository = getRepository(Issue); + const mediaRepository = getRepository(Media); + + const media = await mediaRepository.findOne({ + where: { id: req.body.mediaId }, + }); + + if (!media) { + return next({ status: 404, message: 'Media does not exist.' }); + } + + const issue = new Issue({ + createdBy: req.user, + issueType: req.body.issueType, + problemSeason: req.body.problemSeason, + problemEpisode: req.body.problemEpisode, + media, + comments: [ + new IssueComment({ + user: req.user, + message: req.body.message, + }), + ], + }); + + const newIssue = await issueRepository.save(issue); + + return res.status(200).json(newIssue); + } +); + +issueRoutes.get<{ issueId: string }>( + '/:issueId', + isAuthenticated( + [ + Permission.MANAGE_ISSUES, + Permission.VIEW_ISSUES, + Permission.CREATE_ISSUES, + ], + { type: 'or' } + ), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const issue = await issueRepository + .createQueryBuilder('issue') + .leftJoinAndSelect('issue.comments', 'comments') + .leftJoinAndSelect('issue.createdBy', 'createdBy') + .leftJoinAndSelect('comments.user', 'user') + .leftJoinAndSelect('issue.media', 'media') + .where('issue.id = :issueId', { issueId: Number(req.params.issueId) }) + .getOneOrFail(); + + if ( + issue.createdBy.id !== req.user.id && + !req.user.hasPermission( + [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], + { type: 'or' } + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to view this issue.', + }); + } + + return res.status(200).json(issue); + } catch (e) { + logger.debug('Failed to retrieve issue.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Issue not found.' }); + } + } +); + +issueRoutes.post<{ issueId: string }, Issue, { message: string }>( + '/:issueId/comment', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const issue = await issueRepository.findOneOrFail({ + where: { id: Number(req.params.issueId) }, + }); + + if ( + issue.createdBy.id !== req.user.id && + !req.user.hasPermission(Permission.MANAGE_ISSUES) + ) { + return next({ + status: 403, + message: 'You do not have permission to comment on this issue.', + }); + } + + const comment = new IssueComment({ + message: req.body.message, + user: req.user, + }); + + issue.comments = [...issue.comments, comment]; + + await issueRepository.save(issue); + + return res.status(200).json(issue); + } catch (e) { + logger.debug('Something went wrong creating an issue comment.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Issue not found.' }); + } + } +); + +issueRoutes.post<{ issueId: string; status: string }, Issue>( + '/:issueId/:status', + isAuthenticated(Permission.MANAGE_ISSUES), + async (req, res, next) => { + const issueRepository = getRepository(Issue); + // Satisfy typescript here. User is set, we assure you! + if (!req.user) { + return next({ status: 500, message: 'User missing from request.' }); + } + + try { + const issue = await issueRepository.findOneOrFail({ + where: { id: Number(req.params.issueId) }, + }); + + let newStatus: IssueStatus | undefined; + + switch (req.params.status) { + case 'resolved': + newStatus = IssueStatus.RESOLVED; + break; + case 'open': + newStatus = IssueStatus.OPEN; + } + + if (!newStatus) { + return next({ + status: 400, + message: 'You must provide a valid status', + }); + } + + issue.status = newStatus; + + await issueRepository.save(issue); + + return res.status(200).json(issue); + } catch (e) { + logger.debug('Something went wrong creating an issue comment.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 500, message: 'Issue not found.' }); + } + } +); + +issueRoutes.delete('/:issueId', async (req, res, next) => { + const issueRepository = getRepository(Issue); + + try { + const issue = await issueRepository.findOneOrFail({ + where: { id: Number(req.params.issueId) }, + relations: ['createdBy'], + }); + + if ( + !req.user?.hasPermission(Permission.MANAGE_ISSUES) && + issue.createdBy.id !== req.user?.id + ) { + return next({ + status: 401, + message: 'You do not have permission to delete this issue.', + }); + } + + await issueRepository.remove(issue); + + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong deleting an issue.', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue not found.' }); + } +}); + +export default issueRoutes; diff --git a/server/routes/issueComment.ts b/server/routes/issueComment.ts new file mode 100644 index 000000000..9bc4e27b9 --- /dev/null +++ b/server/routes/issueComment.ts @@ -0,0 +1,132 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import IssueComment from '../entity/IssueComment'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; +import { isAuthenticated } from '../middleware/auth'; + +const issueCommentRoutes = Router(); + +issueCommentRoutes.get<{ commentId: string }, IssueComment>( + '/:commentId', + isAuthenticated( + [ + Permission.MANAGE_ISSUES, + Permission.VIEW_ISSUES, + Permission.CREATE_ISSUES, + ], + { + type: 'or', + } + ), + async (req, res, next) => { + const issueCommentRepository = getRepository(IssueComment); + + try { + const comment = await issueCommentRepository.findOneOrFail({ + where: { id: Number(req.params.commentId) }, + }); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], + { type: 'or' } + ) && + comment.user.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to view this comment.', + }); + } + + return res.status(200).json(comment); + } catch (e) { + logger.debug('Request for unknown issue comment failed', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue comment not found.' }); + } + } +); + +issueCommentRoutes.put< + { commentId: string }, + IssueComment, + { message: string } +>( + '/:commentId', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueCommentRepository = getRepository(IssueComment); + + try { + const comment = await issueCommentRepository.findOneOrFail({ + where: { id: Number(req.params.commentId) }, + }); + + if ( + !req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) && + comment.user.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to edit this comment.', + }); + } + + comment.message = req.body.message; + + await issueCommentRepository.save(comment); + + return res.status(200).json(comment); + } catch (e) { + logger.debug('Put request for issue comment failed', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue comment not found.' }); + } + } +); + +issueCommentRoutes.delete<{ commentId: string }, IssueComment>( + '/:commentId', + isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), + async (req, res, next) => { + const issueCommentRepository = getRepository(IssueComment); + + try { + const comment = await issueCommentRepository.findOneOrFail({ + where: { id: Number(req.params.commentId) }, + }); + + if ( + !req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) && + comment.user.id !== req.user?.id + ) { + return next({ + status: 403, + message: 'You do not have permission to delete this comment.', + }); + } + + await issueCommentRepository.remove(comment); + + return res.status(204).send(); + } catch (e) { + logger.debug('Delete request for issue comment failed', { + label: 'API', + errorMessage: e.message, + }); + next({ status: 404, message: 'Issue comment not found.' }); + } + } +); + +export default issueCommentRoutes; diff --git a/server/routes/request.ts b/server/routes/request.ts index 8fed74107..2eac90f00 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -13,131 +13,134 @@ import { isAuthenticated } from '../middleware/auth'; const requestRoutes = Router(); -requestRoutes.get('/', async (req, res, next) => { - try { - const pageSize = req.query.take ? Number(req.query.take) : 10; - const skip = req.query.skip ? Number(req.query.skip) : 0; - const requestedBy = req.query.requestedBy - ? Number(req.query.requestedBy) - : null; +requestRoutes.get, RequestResultsResponse>( + '/', + async (req, res, next) => { + try { + const pageSize = req.query.take ? Number(req.query.take) : 10; + const skip = req.query.skip ? Number(req.query.skip) : 0; + const requestedBy = req.query.requestedBy + ? Number(req.query.requestedBy) + : null; - let statusFilter: MediaRequestStatus[]; + let statusFilter: MediaRequestStatus[]; - switch (req.query.filter) { - case 'approved': - case 'processing': - case 'available': - statusFilter = [MediaRequestStatus.APPROVED]; - break; - case 'pending': - statusFilter = [MediaRequestStatus.PENDING]; - break; - case 'unavailable': - statusFilter = [ - MediaRequestStatus.PENDING, - MediaRequestStatus.APPROVED, - ]; - break; - default: - statusFilter = [ - MediaRequestStatus.PENDING, - MediaRequestStatus.APPROVED, - MediaRequestStatus.DECLINED, - ]; - } + switch (req.query.filter) { + case 'approved': + case 'processing': + case 'available': + statusFilter = [MediaRequestStatus.APPROVED]; + break; + case 'pending': + statusFilter = [MediaRequestStatus.PENDING]; + break; + case 'unavailable': + statusFilter = [ + MediaRequestStatus.PENDING, + MediaRequestStatus.APPROVED, + ]; + break; + default: + statusFilter = [ + MediaRequestStatus.PENDING, + MediaRequestStatus.APPROVED, + MediaRequestStatus.DECLINED, + ]; + } - let mediaStatusFilter: MediaStatus[]; + let mediaStatusFilter: MediaStatus[]; - switch (req.query.filter) { - case 'available': - mediaStatusFilter = [MediaStatus.AVAILABLE]; - break; - case 'processing': - case 'unavailable': - mediaStatusFilter = [ - MediaStatus.UNKNOWN, - MediaStatus.PENDING, - MediaStatus.PROCESSING, - MediaStatus.PARTIALLY_AVAILABLE, - ]; - break; - default: - mediaStatusFilter = [ - MediaStatus.UNKNOWN, - MediaStatus.PENDING, - MediaStatus.PROCESSING, - MediaStatus.PARTIALLY_AVAILABLE, - MediaStatus.AVAILABLE, - ]; - } + switch (req.query.filter) { + case 'available': + mediaStatusFilter = [MediaStatus.AVAILABLE]; + break; + case 'processing': + case 'unavailable': + mediaStatusFilter = [ + MediaStatus.UNKNOWN, + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + ]; + break; + default: + mediaStatusFilter = [ + MediaStatus.UNKNOWN, + MediaStatus.PENDING, + MediaStatus.PROCESSING, + MediaStatus.PARTIALLY_AVAILABLE, + MediaStatus.AVAILABLE, + ]; + } - let sortFilter: string; + let sortFilter: string; - switch (req.query.sort) { - case 'modified': - sortFilter = 'request.updatedAt'; - break; - default: - sortFilter = 'request.id'; - } + switch (req.query.sort) { + case 'modified': + sortFilter = 'request.updatedAt'; + break; + default: + sortFilter = 'request.id'; + } - let query = getRepository(MediaRequest) - .createQueryBuilder('request') - .leftJoinAndSelect('request.media', 'media') - .leftJoinAndSelect('request.seasons', 'seasons') - .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') - .leftJoinAndSelect('request.requestedBy', 'requestedBy') - .where('request.status IN (:...requestStatus)', { - requestStatus: statusFilter, - }) - .andWhere( - '((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', - { - mediaStatus: mediaStatusFilter, + let query = getRepository(MediaRequest) + .createQueryBuilder('request') + .leftJoinAndSelect('request.media', 'media') + .leftJoinAndSelect('request.seasons', 'seasons') + .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') + .leftJoinAndSelect('request.requestedBy', 'requestedBy') + .where('request.status IN (:...requestStatus)', { + requestStatus: statusFilter, + }) + .andWhere( + '((request.is4k = 0 AND media.status IN (:...mediaStatus)) OR (request.is4k = 1 AND media.status4k IN (:...mediaStatus)))', + { + mediaStatus: mediaStatusFilter, + } + ); + + if ( + !req.user?.hasPermission( + [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], + { type: 'or' } + ) + ) { + if (requestedBy && requestedBy !== req.user?.id) { + return next({ + status: 403, + message: "You do not have permission to view this user's requests.", + }); } - ); - if ( - !req.user?.hasPermission( - [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], - { type: 'or' } - ) - ) { - if (requestedBy && requestedBy !== req.user?.id) { - return next({ - status: 403, - message: "You do not have permission to view this user's requests.", + query = query.andWhere('requestedBy.id = :id', { + id: req.user?.id, + }); + } else if (requestedBy) { + query = query.andWhere('requestedBy.id = :id', { + id: requestedBy, }); } - query = query.andWhere('requestedBy.id = :id', { - id: req.user?.id, - }); - } else if (requestedBy) { - query = query.andWhere('requestedBy.id = :id', { - id: requestedBy, + const [requests, requestCount] = await query + .orderBy(sortFilter, 'DESC') + .take(pageSize) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(requestCount / pageSize), + pageSize, + results: requestCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: requests, }); + } catch (e) { + next({ status: 500, message: e.message }); } - - const [requests, requestCount] = await query - .orderBy(sortFilter, 'DESC') - .take(pageSize) - .skip(skip) - .getManyAndCount(); - - return res.status(200).json({ - pageInfo: { - pages: Math.ceil(requestCount / pageSize), - pageSize, - results: requestCount, - page: Math.ceil(skip / pageSize) + 1, - }, - results: requests, - } as RequestResultsResponse); - } catch (e) { - next({ status: 500, message: e.message }); } -}); +); requestRoutes.post('/', async (req, res, next) => { const tmdb = new TheMovieDb(); @@ -665,7 +668,10 @@ requestRoutes.delete('/:requestId', async (req, res, next) => { return res.status(204).send(); } catch (e) { - logger.error(e.message); + logger.error('Something went wrong deleting a request.', { + label: 'API', + errorMessage: e.message, + }); next({ status: 404, message: 'Request not found.' }); } }); diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts new file mode 100644 index 000000000..aab6bd94d --- /dev/null +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -0,0 +1,65 @@ +import { + EntitySubscriberInterface, + EventSubscriber, + getRepository, + InsertEvent, +} from 'typeorm'; +import TheMovieDb from '../api/themoviedb'; +import { MediaType } from '../constants/media'; +import IssueComment from '../entity/IssueComment'; +import notificationManager, { Notification } from '../lib/notifications'; + +@EventSubscriber() +export class IssueCommentSubscriber + implements EntitySubscriberInterface +{ + public listenTo(): typeof IssueComment { + return IssueComment; + } + + private async sendIssueCommentNotification(entity: IssueComment) { + const issueCommentRepository = getRepository(IssueComment); + let title: string; + let image: string; + const tmdb = new TheMovieDb(); + const issuecomment = await issueCommentRepository.findOne({ + where: { id: entity.id }, + relations: ['issue'], + }); + + const issue = issuecomment?.issue; + + if (!issue) { + return; + } + + if (issue.media.mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: issue.media.tmdbId }); + + title = movie.title; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; + } else { + const tvshow = await tmdb.getTvShow({ tvId: issue.media.tmdbId }); + + title = tvshow.name; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; + } + + notificationManager.sendNotification(Notification.ISSUE_COMMENT, { + subject: `New Issue Comment: ${title}`, + message: entity.message, + issue, + image, + notifyUser: + issue.createdBy.id !== entity.user.id ? issue.createdBy : undefined, + }); + } + + public afterInsert(event: InsertEvent): void { + if (!event.entity) { + return; + } + + this.sendIssueCommentNotification(event.entity); + } +} diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts new file mode 100644 index 000000000..e76d2fd7b --- /dev/null +++ b/server/subscriber/IssueSubscriber.ts @@ -0,0 +1,50 @@ +import { + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, +} from 'typeorm'; +import TheMovieDb from '../api/themoviedb'; +import { MediaType } from '../constants/media'; +import Issue from '../entity/Issue'; +import notificationManager, { Notification } from '../lib/notifications'; + +@EventSubscriber() +export class IssueSubscriber implements EntitySubscriberInterface { + public listenTo(): typeof Issue { + return Issue; + } + + private async sendIssueCreatedNotification(entity: Issue) { + let title: string; + let image: string; + const tmdb = new TheMovieDb(); + if (entity.media.mediaType === MediaType.MOVIE) { + const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); + + title = movie.title; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; + } else { + const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); + + title = tvshow.name; + image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; + } + + const [firstComment] = entity.comments; + + notificationManager.sendNotification(Notification.ISSUE_CREATED, { + subject: title, + message: firstComment.message, + issue: entity, + image, + }); + } + + public afterInsert(event: InsertEvent): void { + if (!event.entity) { + return; + } + + this.sendIssueCreatedNotification(event.entity); + } +} diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index fb9bf24c2..f50e1d664 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -13,7 +13,7 @@ import Season from '../entity/Season'; import notificationManager, { Notification } from '../lib/notifications'; @EventSubscriber() -export class MediaSubscriber implements EntitySubscriberInterface { +export class MediaSubscriber implements EntitySubscriberInterface { private async notifyAvailableMovie(entity: Media, dbEntity?: Media) { if ( entity.status === MediaStatus.AVAILABLE && @@ -169,4 +169,8 @@ export class MediaSubscriber implements EntitySubscriberInterface { this.updateChildRequestStatus(event.entity as Media, true); } } + + public listenTo(): typeof Media { + return Media; + } } diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 56b368d90..bfd654e99 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -323,7 +323,9 @@ const CollectionDetails: React.FC = ({ .map((t, k) => {t}) .reduce((prev, curr) => ( <> - {prev} | {curr} + {prev} + | + {curr} ))} diff --git a/src/components/Common/SlideOver/index.tsx b/src/components/Common/SlideOver/index.tsx index 736a4a6e2..2d49e6a96 100644 --- a/src/components/Common/SlideOver/index.tsx +++ b/src/components/Common/SlideOver/index.tsx @@ -7,7 +7,7 @@ import Transition from '../../Transition'; interface SlideOverProps { show?: boolean; - title: string; + title: React.ReactNode; subText?: string; onClose: () => void; } diff --git a/src/components/IssueBlock/index.tsx b/src/components/IssueBlock/index.tsx new file mode 100644 index 000000000..7e8067c47 --- /dev/null +++ b/src/components/IssueBlock/index.tsx @@ -0,0 +1,68 @@ +import { + CalendarIcon, + ExclamationIcon, + EyeIcon, + UserIcon, +} from '@heroicons/react/solid'; +import Link from 'next/link'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import type Issue from '../../../server/entity/Issue'; +import Button from '../Common/Button'; +import { issueOptions } from '../IssueModal/constants'; + +interface IssueBlockProps { + issue: Issue; +} + +const IssueBlock: React.FC = ({ issue }) => { + const intl = useIntl(); + const issueOption = issueOptions.find( + (opt) => opt.issueType === issue.issueType + ); + + if (!issueOption) { + return null; + } + + return ( +
+
+
+
+ + + {intl.formatMessage(issueOption.name)} + +
+
+ + + {issue.createdBy.displayName} + +
+
+ + + {intl.formatDate(issue.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+
+ + + +
+
+
+ ); +}; + +export default IssueBlock; diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx new file mode 100644 index 000000000..603616da3 --- /dev/null +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -0,0 +1,263 @@ +import { Menu } from '@headlessui/react'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { DotsVerticalIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import React, { useState } from 'react'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; +import ReactMarkdown from 'react-markdown'; +import * as Yup from 'yup'; +import type { default as IssueCommentType } from '../../../../server/entity/IssueComment'; +import { Permission, useUser } from '../../../hooks/useUser'; +import Button from '../../Common/Button'; +import Modal from '../../Common/Modal'; +import Transition from '../../Transition'; + +const messages = defineMessages({ + postedby: 'Posted by {username} {relativeTime}', + postedbyedited: 'Posted by {username} {relativeTime} (Edited)', + delete: 'Delete Comment', + areyousuredelete: 'Are you sure you want to delete this comment?', + validationComment: 'You must provide a message', + edit: 'Edit Comment', +}); + +interface IssueCommentProps { + comment: IssueCommentType; + isReversed?: boolean; + isActiveUser?: boolean; + onUpdate?: () => void; +} + +const IssueComment: React.FC = ({ + comment, + isReversed = false, + isActiveUser = false, + onUpdate, +}) => { + const intl = useIntl(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const { user, hasPermission } = useUser(); + + const EditCommentSchema = Yup.object().shape({ + newMessage: Yup.string().required( + intl.formatMessage(messages.validationComment) + ), + }); + + const deleteComment = async () => { + try { + await axios.delete(`/api/v1/issueComment/${comment.id}`); + } catch (e) { + // something went wrong deleting the comment + } finally { + if (onUpdate) { + onUpdate(); + } + } + }; + + const belongsToUser = comment.user.id === user?.id; + + return ( +
+ + setShowDeleteModal(false)} + onOk={() => deleteComment()} + okText={intl.formatMessage(messages.delete)} + okButtonType="danger" + iconSvg={} + > + {intl.formatMessage(messages.areyousuredelete)} + + + +
+
+ {(belongsToUser || hasPermission(Permission.MANAGE_ISSUES)) && ( + + {({ open }) => ( + <> +
+ + Open options + +
+ + + +
+ + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + +
+
+
+ + )} +
+ )} +
+
+ {isEditing ? ( + { + await axios.put(`/api/v1/issueComment/${comment.id}`, { + message: values.newMessage, + }); + + if (onUpdate) { + onUpdate(); + } + + setIsEditing(false); + }} + validationSchema={EditCommentSchema} + > + {({ isValid, isSubmitting, errors, touched }) => { + return ( +
+ + {errors.newMessage && touched.newMessage && ( +
{errors.newMessage}
+ )} +
+ + +
+ + ); + }} +
+ ) : ( +
+ + {comment.message} + +
+ )} +
+
+
+ + {intl.formatMessage( + comment.createdAt !== comment.updatedAt + ? messages.postedbyedited + : messages.postedby, + { + username: ( + + {comment.user.displayName} + + ), + relativeTime: ( + + ), + } + )} + +
+
+
+ ); +}; + +export default IssueComment; diff --git a/src/components/IssueDetails/IssueDescription/index.tsx b/src/components/IssueDetails/IssueDescription/index.tsx new file mode 100644 index 000000000..ba550afbd --- /dev/null +++ b/src/components/IssueDetails/IssueDescription/index.tsx @@ -0,0 +1,152 @@ +import { Menu, Transition } from '@headlessui/react'; +import { DotsVerticalIcon } from '@heroicons/react/solid'; +import { Field, Form, Formik } from 'formik'; +import React, { Fragment, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import ReactMarkdown from 'react-markdown'; +import { Permission, useUser } from '../../../hooks/useUser'; +import Button from '../../Common/Button'; + +const messages = defineMessages({ + description: 'Description', + edit: 'Edit Description', + cancel: 'Cancel', + save: 'Save Changes', + deleteissue: 'Delete Issue', +}); + +interface IssueDescriptionProps { + issueId: number; + description: string; + onEdit: (newDescription: string) => void; + onDelete: () => void; +} + +const IssueDescription: React.FC = ({ + issueId, + description, + onEdit, + onDelete, +}) => { + const intl = useIntl(); + const { user, hasPermission } = useUser(); + const [isEditing, setIsEditing] = useState(false); + + return ( +
+
+
+ {intl.formatMessage(messages.description)} +
+ {(hasPermission(Permission.MANAGE_ISSUES) || user?.id === issueId) && ( + + {({ open }) => ( + <> +
+ + Open options + +
+ + + +
+ + {({ active }) => ( + + )} + + + + {({ active }) => ( + + )} + +
+
+
+ + )} +
+ )} +
+ {isEditing ? ( + { + onEdit(values.newMessage); + setIsEditing(false); + }} + > + {() => { + return ( +
+ +
+ + +
+ + ); + }} +
+ ) : ( +
+ + {description} + +
+ )} +
+ ); +}; + +export default IssueDescription; diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx new file mode 100644 index 000000000..46ba759e8 --- /dev/null +++ b/src/components/IssueDetails/index.tsx @@ -0,0 +1,600 @@ +import { + ChatIcon, + CheckCircleIcon, + ExclamationIcon, + ExternalLinkIcon, +} from '@heroicons/react/outline'; +import { RefreshIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React, { useState } from 'react'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import { IssueStatus } from '../../../server/constants/issue'; +import { MediaType } from '../../../server/constants/media'; +import type Issue from '../../../server/entity/Issue'; +import type { MovieDetails } from '../../../server/models/Movie'; +import type { TvDetails } from '../../../server/models/Tv'; +import { Permission, useUser } from '../../hooks/useUser'; +import globalMessages from '../../i18n/globalMessages'; +import Error from '../../pages/_error'; +import Badge from '../Common/Badge'; +import Button from '../Common/Button'; +import CachedImage from '../Common/CachedImage'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import Modal from '../Common/Modal'; +import PageTitle from '../Common/PageTitle'; +import { issueOptions } from '../IssueModal/constants'; +import Transition from '../Transition'; +import IssueComment from './IssueComment'; +import IssueDescription from './IssueDescription'; + +const messages = defineMessages({ + openedby: + '#{issueId} opened {relativeTime} by {username}', + closeissue: 'Close Issue', + closeissueandcomment: 'Close with Comment', + leavecomment: 'Comment', + comments: 'Comments', + reopenissue: 'Reopen Issue', + reopenissueandcomment: 'Reopen with Comment', + issuepagetitle: 'Issue', + openinradarr: 'Open in Radarr', + openinsonarr: 'Open in Sonarr', + toasteditdescriptionsuccess: 'Successfully edited the issue description.', + toasteditdescriptionfailed: 'Something went wrong editing the description.', + toaststatusupdated: 'Issue status updated.', + toaststatusupdatefailed: 'Something went wrong updating the issue status.', + issuetype: 'Issue Type', + mediatype: 'Media Type', + lastupdated: 'Last Updated', + statusopen: 'Open', + statusresolved: 'Resolved', + problemseason: 'Affected Season', + allseasons: 'All Seasons', + season: 'Season {seasonNumber}', + problemepisode: 'Affected Episode', + allepisodes: 'All Episodes', + episode: 'Episode {episodeNumber}', + deleteissue: 'Delete Issue', + deleteissueconfirm: 'Are you sure you want to delete this issue?', + toastissuedeleted: 'Issue deleted succesfully.', + toastissuedeletefailed: 'Something went wrong deleting the issue.', + nocomments: 'No comments.', + unknownissuetype: 'Unknown', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const IssueDetails: React.FC = () => { + const { addToast } = useToasts(); + const router = useRouter(); + const intl = useIntl(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const { user: currentUser, hasPermission } = useUser(); + const { data: issueData, revalidate: revalidateIssue } = useSWR( + `/api/v1/issue/${router.query.issueId}` + ); + const { data, error } = useSWR( + issueData?.media.tmdbId + ? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}` + : null + ); + + const CommentSchema = Yup.object().shape({ + message: Yup.string().required(), + }); + + const issueOption = issueOptions.find( + (opt) => opt.issueType === issueData?.issueType + ); + + const mediaType = issueData?.media.mediaType; + + if (!data && !error) { + return ; + } + + if (!data || !issueData) { + return ; + } + + const belongsToUser = issueData.createdBy.id === currentUser?.id; + + const [firstComment, ...otherComments] = issueData.comments; + + const editFirstComment = async (newMessage: string) => { + try { + await axios.put(`/api/v1/issueComment/${firstComment.id}`, { + message: newMessage, + }); + + addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), { + appearance: 'success', + autoDismiss: true, + }); + revalidateIssue(); + } catch (e) { + addToast(intl.formatMessage(messages.toasteditdescriptionfailed), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const updateIssueStatus = async (newStatus: 'open' | 'resolved') => { + try { + await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`); + + addToast(intl.formatMessage(messages.toaststatusupdated), { + appearance: 'success', + autoDismiss: true, + }); + revalidateIssue(); + } catch (e) { + addToast(intl.formatMessage(messages.toaststatusupdatefailed), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const deleteIssue = async () => { + try { + await axios.delete(`/api/v1/issue/${issueData.id}`); + + addToast(intl.formatMessage(messages.toastissuedeleted), { + appearance: 'success', + autoDismiss: true, + }); + router.push('/issues'); + } catch (e) { + addToast(intl.formatMessage(messages.toastissuedeletefailed), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const title = isMovie(data) ? data.title : data.name; + const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate; + + return ( +
+ + + setShowDeleteModal(false)} + onOk={() => deleteIssue()} + okText={intl.formatMessage(messages.deleteissue)} + okButtonType="danger" + iconSvg={} + > + {intl.formatMessage(messages.deleteissueconfirm)} + + + {data.backdropPath && ( +
+ +
+
+ )} +
+
+ +
+
+
+ {issueData.status === IssueStatus.OPEN && ( + + {intl.formatMessage(messages.statusopen)} + + )} + {issueData.status === IssueStatus.RESOLVED && ( + + {intl.formatMessage(messages.statusresolved)} + + )} +
+

+ + + {title}{' '} + {releaseYear && ( + + ({releaseYear.slice(0, 4)}) + + )} + + +

+ + {intl.formatMessage(messages.openedby, { + issueId: issueData.id, + username: issueData.createdBy.displayName, + UserLink: function UserLink(msg) { + return ( +
+ + + + + + + + {msg} + + +
+ ); + }, + relativeTime: ( + + ), + })} +
+
+
+
+
+ { + editFirstComment(newMessage); + }} + onDelete={() => setShowDeleteModal(true)} + /> +
+
+
+ {intl.formatMessage(messages.mediatype)} + + {intl.formatMessage( + mediaType === MediaType.MOVIE + ? globalMessages.movie + : globalMessages.tvshow + )} + +
+
+ {intl.formatMessage(messages.issuetype)} + + {intl.formatMessage( + issueOption?.name ?? messages.unknownissuetype + )} + +
+ {issueData.media.mediaType === MediaType.TV && ( + <> +
+ {intl.formatMessage(messages.problemseason)} + + {intl.formatMessage( + issueData.problemSeason > 0 + ? messages.season + : messages.allseasons, + { seasonNumber: issueData.problemSeason } + )} + +
+ {issueData.problemSeason > 0 && ( +
+ {intl.formatMessage(messages.problemepisode)} + + {intl.formatMessage( + issueData.problemEpisode > 0 + ? messages.episode + : messages.allepisodes, + { episodeNumber: issueData.problemEpisode } + )} + +
+ )} + + )} +
+ {intl.formatMessage(messages.lastupdated)} + + + +
+
+ {hasPermission(Permission.MANAGE_ISSUES) && ( +
+ {issueData?.media.serviceUrl && ( + + )} +
+ )} +
+
+
+ {intl.formatMessage(messages.comments)} +
+ {otherComments.map((comment) => ( + revalidateIssue()} + /> + ))} + {otherComments.length === 0 && ( +
+ {intl.formatMessage(messages.nocomments)} +
+ )} + {(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && ( + { + await axios.post(`/api/v1/issue/${issueData?.id}/comment`, { + message: values.message, + }); + revalidateIssue(); + resetForm(); + }} + > + {({ isValid, isSubmitting, values, handleSubmit }) => { + return ( +
+
+ +
+ {hasPermission(Permission.MANAGE_ISSUES) && ( + <> + {issueData.status === IssueStatus.OPEN ? ( + + ) : ( + + )} + + )} + +
+
+
+ ); + }} +
+ )} +
+
+
+
+
+ {intl.formatMessage(messages.issuetype)} + + {intl.formatMessage( + issueOption?.name ?? messages.unknownissuetype + )} + +
+
+ {intl.formatMessage(messages.mediatype)} + + {intl.formatMessage( + mediaType === MediaType.MOVIE + ? globalMessages.movie + : globalMessages.tvshow + )} + +
+ {issueData.media.mediaType === MediaType.TV && ( + <> +
+ {intl.formatMessage(messages.problemseason)} + + {intl.formatMessage( + issueData.problemSeason > 0 + ? messages.season + : messages.allseasons, + { seasonNumber: issueData.problemSeason } + )} + +
+ {issueData.problemSeason > 0 && ( +
+ {intl.formatMessage(messages.problemepisode)} + + {intl.formatMessage( + issueData.problemEpisode > 0 + ? messages.episode + : messages.allepisodes, + { episodeNumber: issueData.problemEpisode } + )} + +
+ )} + + )} +
+ {intl.formatMessage(messages.lastupdated)} + + + +
+
+ {hasPermission(Permission.MANAGE_ISSUES) && ( +
+ {issueData?.media.serviceUrl && ( + + )} +
+ )} +
+
+
+ ); +}; + +export default IssueDetails; diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx new file mode 100644 index 000000000..25cb758ac --- /dev/null +++ b/src/components/IssueList/IssueItem/index.tsx @@ -0,0 +1,257 @@ +import { EyeIcon } from '@heroicons/react/solid'; +import Link from 'next/link'; +import React from 'react'; +import { useInView } from 'react-intersection-observer'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import { IssueStatus } from '../../../../server/constants/issue'; +import { MediaType } from '../../../../server/constants/media'; +import Issue from '../../../../server/entity/Issue'; +import { MovieDetails } from '../../../../server/models/Movie'; +import { TvDetails } from '../../../../server/models/Tv'; +import { Permission, useUser } from '../../../hooks/useUser'; +import globalMessages from '../../../i18n/globalMessages'; +import Badge from '../../Common/Badge'; +import Button from '../../Common/Button'; +import CachedImage from '../../Common/CachedImage'; +import { issueOptions } from '../../IssueModal/constants'; + +const messages = defineMessages({ + openeduserdate: '{date} by {user}', + allseasons: 'All Seasons', + season: 'Season {seasonNumber}', + problemepisode: 'Affected Episode', + allepisodes: 'All Episodes', + episode: 'Episode {episodeNumber}', + issuetype: 'Type', + issuestatus: 'Status', + opened: 'Opened', + viewissue: 'View Issue', + unknownissuetype: 'Unknown', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +interface IssueItemProps { + issue: Issue; +} + +const IssueItem: React.FC = ({ issue }) => { + const intl = useIntl(); + const { hasPermission } = useUser(); + const { ref, inView } = useInView({ + triggerOnce: true, + }); + const url = + issue.media.mediaType === 'movie' + ? `/api/v1/movie/${issue.media.tmdbId}` + : `/api/v1/tv/${issue.media.tmdbId}`; + const { data: title, error } = useSWR( + inView ? url : null + ); + + if (!title && !error) { + return ( +
+ ); + } + + if (!title) { + return
uh oh
; + } + + const issueOption = issueOptions.find( + (opt) => opt.issueType === issue?.issueType + ); + + const problemSeasonEpisodeLine = []; + + if (!isMovie(title) && issue) { + problemSeasonEpisodeLine.push( + issue.problemSeason > 0 + ? intl.formatMessage(messages.season, { + seasonNumber: issue.problemSeason, + }) + : intl.formatMessage(messages.allseasons) + ); + + if (issue.problemSeason > 0) { + problemSeasonEpisodeLine.push( + issue.problemEpisode > 0 + ? intl.formatMessage(messages.episode, { + episodeNumber: issue.problemEpisode, + }) + : intl.formatMessage(messages.allepisodes) + ); + } + } + + return ( +
+ {title.backdropPath && ( +
+ +
+
+ )} +
+
+ + + + + +
+
+ {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( + 0, + 4 + )} +
+ + + {isMovie(title) ? title.title : title.name} + + + {problemSeasonEpisodeLine.length > 0 && ( +
+ {problemSeasonEpisodeLine.join(' | ')} +
+ )} +
+
+
+
+ + {intl.formatMessage(messages.issuestatus)} + + {issue.status === IssueStatus.OPEN ? ( + + {intl.formatMessage(globalMessages.open)} + + ) : ( + + {intl.formatMessage(globalMessages.resolved)} + + )} +
+
+ + {intl.formatMessage(messages.issuetype)} + + + {intl.formatMessage( + issueOption?.name ?? messages.unknownissuetype + )} + +
+
+ {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { + type: 'or', + }) ? ( + <> + + {intl.formatMessage(messages.opened)} + + + {intl.formatMessage(messages.openeduserdate, { + date: ( + + ), + user: ( + + + + + {issue.createdBy.displayName} + + + + ), + })} + + + ) : ( + <> + + {intl.formatMessage(messages.opened)} + + + + + + )} +
+
+
+
+ + + + + +
+
+ ); +}; + +export default IssueItem; diff --git a/src/components/IssueList/index.tsx b/src/components/IssueList/index.tsx new file mode 100644 index 000000000..8a2559a13 --- /dev/null +++ b/src/components/IssueList/index.tsx @@ -0,0 +1,256 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + FilterIcon, + SortDescendingIcon, +} from '@heroicons/react/solid'; +import { useRouter } from 'next/router'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; +import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces'; +import Button from '../../components/Common/Button'; +import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams'; +import globalMessages from '../../i18n/globalMessages'; +import Header from '../Common/Header'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import PageTitle from '../Common/PageTitle'; +import IssueItem from './IssueItem'; + +const messages = defineMessages({ + issues: 'Issues', + sortAdded: 'Request Date', + sortModified: 'Last Modified', + showallissues: 'Show All Issues', +}); + +enum Filter { + ALL = 'all', + OPEN = 'open', + RESOLVED = 'resolved', +} + +type Sort = 'added' | 'modified'; + +const IssueList: React.FC = () => { + const intl = useIntl(); + const router = useRouter(); + const [currentFilter, setCurrentFilter] = useState(Filter.OPEN); + const [currentSort, setCurrentSort] = useState('added'); + const [currentPageSize, setCurrentPageSize] = useState(10); + + const page = router.query.page ? Number(router.query.page) : 1; + const pageIndex = page - 1; + const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + + const { data, error } = useSWR( + `/api/v1/issue?take=${currentPageSize}&skip=${ + pageIndex * currentPageSize + }&filter=${currentFilter}&sort=${currentSort}` + ); + + // Restore last set filter values on component mount + useEffect(() => { + const filterString = window.localStorage.getItem('il-filter-settings'); + + if (filterString) { + const filterSettings = JSON.parse(filterString); + + setCurrentFilter(filterSettings.currentFilter); + setCurrentSort(filterSettings.currentSort); + setCurrentPageSize(filterSettings.currentPageSize); + } + + // If filter value is provided in query, use that instead + if (Object.values(Filter).includes(router.query.filter as Filter)) { + setCurrentFilter(router.query.filter as Filter); + } + }, [router.query.filter]); + + // Set filter values to local storage any time they are changed + useEffect(() => { + window.localStorage.setItem( + 'il-filter-settings', + JSON.stringify({ + currentFilter, + currentSort, + currentPageSize, + }) + ); + }, [currentFilter, currentSort, currentPageSize]); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + const hasNextPage = data.pageInfo.pages > pageIndex + 1; + const hasPrevPage = pageIndex > 0; + + return ( + <> + +
+
Issues
+
+
+ + + + +
+
+ + + + +
+
+
+ {data.results.map((issue) => { + return ( +
+ +
+ ); + })} + {data.results.length === 0 && ( +
+ + {intl.formatMessage(globalMessages.noresults)} + + {currentFilter !== Filter.ALL && ( +
+ +
+ )} +
+ )} +
+ +
+ + ); +}; + +export default IssueList; diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx new file mode 100644 index 000000000..187fe0e54 --- /dev/null +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -0,0 +1,303 @@ +import { RadioGroup } from '@headlessui/react'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { ArrowCircleRightIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Formik } from 'formik'; +import Link from 'next/link'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import type Issue from '../../../../server/entity/Issue'; +import { MovieDetails } from '../../../../server/models/Movie'; +import { TvDetails } from '../../../../server/models/Tv'; +import globalMessages from '../../../i18n/globalMessages'; +import Button from '../../Common/Button'; +import Modal from '../../Common/Modal'; +import { issueOptions } from '../constants'; + +const messages = defineMessages({ + validationMessageRequired: 'You must provide a description', + issomethingwrong: 'Is there a problem with {title}?', + whatswrong: "What's wrong?", + providedetail: 'Provide a detailed explanation of the issue.', + season: 'Season {seasonNumber}', + episode: 'Episode {episodeNumber}', + allseasons: 'All Seasons', + allepisodes: 'All Episodes', + problemseason: 'Affected Season', + problemepisode: 'Affected Episode', + toastSuccessCreate: + 'Issue report for {title} submitted successfully!', + toastFailedCreate: 'Something went wrong while submitting the issue.', + toastviewissue: 'View Issue', + reportissue: 'Report an Issue', + submitissue: 'Submit Issue', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const classNames = (...classes: string[]) => { + return classes.filter(Boolean).join(' '); +}; + +interface CreateIssueModalProps { + mediaType: 'movie' | 'tv'; + tmdbId?: number; + onCancel?: () => void; +} + +const CreateIssueModal: React.FC = ({ + onCancel, + mediaType, + tmdbId, +}) => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error } = useSWR( + tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null + ); + + if (!tmdbId) { + return null; + } + + const CreateIssueModalSchema = Yup.object().shape({ + message: Yup.string().required( + intl.formatMessage(messages.validationMessageRequired) + ), + }); + + return ( + { + try { + const newIssue = await axios.post('/api/v1/issue', { + issueType: values.selectedIssue.issueType, + message: values.message, + mediaId: data?.mediaInfo?.id, + problemSeason: values.problemSeason, + problemEpisode: + values.problemSeason > 0 ? values.problemEpisode : 0, + }); + + if (data) { + addToast( + <> +
+ {intl.formatMessage(messages.toastSuccessCreate, { + title: isMovie(data) ? data.title : data.name, + strong: function strong(msg) { + return {msg}; + }, + })} +
+ + + + , + { + appearance: 'success', + autoDismiss: true, + } + ); + } + + if (onCancel) { + onCancel(); + } + } catch (e) { + addToast(intl.formatMessage(messages.toastFailedCreate), { + appearance: 'error', + autoDismiss: true, + }); + } + }} + > + {({ handleSubmit, values, setFieldValue, errors, touched }) => { + return ( + } + title={intl.formatMessage(messages.reportissue)} + cancelText={intl.formatMessage(globalMessages.close)} + onOk={() => handleSubmit()} + okText={intl.formatMessage(messages.submitissue)} + loading={!data && !error} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} + > + {data && ( +
+ + {intl.formatMessage(messages.issomethingwrong, { + title: isMovie(data) ? data.title : data.name, + })} + +
+ )} + {mediaType === 'tv' && data && !isMovie(data) && ( + <> +
+ +
+
+ + + {data.seasons.map((season) => ( + + ))} + +
+
+
+ {values.problemSeason > 0 && ( +
+ +
+
+ + + {[ + ...Array( + data.seasons.find( + (season) => + Number(values.problemSeason) === + season.seasonNumber + )?.episodeCount ?? 0 + ), + ].map((i, index) => ( + + ))} + +
+
+
+ )} + + )} + setFieldValue('selectedIssue', issue)} + className="mt-4" + > + + Select an Issue + +
+ {issueOptions.map((setting, index) => ( + + classNames( + index === 0 ? 'rounded-tl-md rounded-tr-md' : '', + index === issueOptions.length - 1 + ? 'rounded-bl-md rounded-br-md' + : '', + checked + ? 'bg-indigo-600 border-indigo-500 z-10' + : 'border-gray-500', + 'relative border p-4 flex cursor-pointer focus:outline-none' + ) + } + > + {({ active, checked }) => ( + <> + + ))} +
+
+
+ + {intl.formatMessage(messages.whatswrong)}{' '} + * + + + {errors.message && touched.message && ( +
{errors.message}
+ )} +
+
+ ); + }} +
+ ); +}; + +export default CreateIssueModal; diff --git a/src/components/IssueModal/constants.ts b/src/components/IssueModal/constants.ts new file mode 100644 index 000000000..4c5b13e4b --- /dev/null +++ b/src/components/IssueModal/constants.ts @@ -0,0 +1,34 @@ +import { defineMessages, MessageDescriptor } from 'react-intl'; +import { IssueType } from '../../../server/constants/issue'; + +const messages = defineMessages({ + issueAudio: 'Audio', + issueVideo: 'Video', + issueSubtitles: 'Subtitles', + issueOther: 'Other', +}); + +interface IssueOption { + name: MessageDescriptor; + issueType: IssueType; + mediaType?: 'movie' | 'tv'; +} + +export const issueOptions: IssueOption[] = [ + { + name: messages.issueVideo, + issueType: IssueType.VIDEO, + }, + { + name: messages.issueAudio, + issueType: IssueType.AUDIO, + }, + { + name: messages.issueSubtitles, + issueType: IssueType.SUBTITLES, + }, + { + name: messages.issueOther, + issueType: IssueType.OTHER, + }, +]; diff --git a/src/components/IssueModal/index.tsx b/src/components/IssueModal/index.tsx new file mode 100644 index 000000000..f3f226de9 --- /dev/null +++ b/src/components/IssueModal/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Transition from '../Transition'; +import CreateIssueModal from './CreateIssueModal'; + +interface IssueModalProps { + show?: boolean; + onCancel: () => void; + mediaType: 'movie' | 'tv'; + tmdbId: number; + issueId?: never; +} + +const IssueModal: React.FC = ({ + show, + mediaType, + onCancel, + tmdbId, +}) => ( + + + +); + +export default IssueModal; diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 689faf909..92495db6b 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -1,6 +1,7 @@ import { ClockIcon, CogIcon, + ExclamationIcon, SparklesIcon, UsersIcon, XIcon, @@ -17,6 +18,7 @@ import VersionStatus from '../VersionStatus'; const messages = defineMessages({ dashboard: 'Discover', requests: 'Requests', + issues: 'Issues', users: 'Users', settings: 'Settings', }); @@ -33,6 +35,7 @@ interface SidebarLinkProps { activeRegExp: RegExp; as?: string; requiredPermission?: Permission | Permission[]; + permissionType?: 'and' | 'or'; } const SidebarLinks: SidebarLinkProps[] = [ @@ -48,6 +51,20 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/requests/, }, + { + href: '/issues', + messagesKey: 'issues', + svgIcon: ( + + ), + activeRegExp: /^\/issues/, + requiredPermission: [ + Permission.MANAGE_ISSUES, + Permission.CREATE_ISSUES, + Permission.VIEW_ISSUES, + ], + permissionType: 'or', + }, { href: '/users', messagesKey: 'users', @@ -121,7 +138,9 @@ const Sidebar: React.FC = ({ open, setClosed }) => {
)} - setShowIssueModal(false)} + show={showIssueModal} + mediaType="movie" + tmdbId={data.id} + /> + setShowManager(false)} - subText={data.title} - > - {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || - (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( - <> -

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

-
-
    - {data.mediaInfo?.downloadStatus?.map((status, index) => ( -
  • - -
  • - ))} - {data.mediaInfo?.downloadStatus4k?.map((status, index) => ( -
  • - -
  • - ))} -
-
- - )} - {data?.mediaInfo && - (data.mediaInfo.status !== MediaStatus.AVAILABLE || - (data.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.movie4kEnabled)) && ( -
- {data?.mediaInfo && - data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( -
- -
- )} - {data?.mediaInfo && - data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.movie4kEnabled && ( -
- -
- )} -
- )} -

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

-
-
    - {data.mediaInfo?.requests?.map((request) => ( -
  • - revalidate()} /> -
  • - ))} - {(data.mediaInfo?.requests ?? []).length === 0 && ( -
  • - {intl.formatMessage(messages.manageModalNoRequests)} -
  • - )} -
-
- {(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( -
- {data?.mediaInfo?.serviceUrl && ( - - - - )} - {data?.mediaInfo?.serviceUrl4k && ( - - - - )} -
- )} - {data?.mediaInfo && ( -
- deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.manageModalClearMedia)} - -
- {intl.formatMessage(messages.manageModalClearMediaWarning)} -
-
- )} -
+ revalidate={() => revalidate()} + show={showManager} + />
= ({ movie }) => { .map((t, k) => {t}) .reduce((prev, curr) => ( <> - {prev} | {curr} + {prev} + | + {curr} ))} @@ -475,13 +329,39 @@ const MovieDetails: React.FC = ({ movie }) => { tmdbId={data.id} onUpdate={() => revalidate()} /> + {(data.mediaInfo?.status === MediaStatus.AVAILABLE || + data.mediaInfo?.status4k === MediaStatus.AVAILABLE) && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { + type: 'or', + } + ) && ( + + )} {hasPermission(Permission.MANAGE_REQUESTS) && ( )}
diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index 0b71f6709..37ecf9859 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -37,6 +37,17 @@ const messages = defineMessages({ 'Send notifications when media requests are declined.', usermediadeclinedDescription: 'Get notified when your media requests are declined.', + issuecreated: 'Issue Created', + issuecreatedDescription: 'Send notifications when new issues are created.', + issuecomment: 'Issue Comment', + issuecommentDescription: + 'Send notifications when issues receive new comments.', + userissuecommentDescription: + 'Send notifications when your issue receives new comments.', + issueresolved: 'Issue Resolved', + issueresolvedDescription: 'Send notifications when issues are resolved.', + userissueresolvedDescription: + 'Send notifications when your issues are resolved.', }); export const hasNotificationType = ( @@ -74,6 +85,9 @@ export enum Notification { TEST_NOTIFICATION = 32, MEDIA_DECLINED = 64, MEDIA_AUTO_APPROVED = 128, + ISSUE_CREATED = 256, + ISSUE_COMMENT = 512, + ISSUE_RESOLVED = 1024, } export const ALL_NOTIFICATIONS = Object.values(Notification) @@ -232,6 +246,35 @@ const NotificationTypeSelector: React.FC = ({ value: Notification.MEDIA_FAILED, hidden: user && !hasPermission(Permission.MANAGE_REQUESTS), }, + { + id: 'issue-created', + name: intl.formatMessage(messages.issuecreated), + description: intl.formatMessage(messages.issuecreatedDescription), + value: Notification.ISSUE_CREATED, + hidden: user && !hasPermission(Permission.MANAGE_ISSUES), + }, + { + id: 'issue-comment', + name: intl.formatMessage(messages.issuecomment), + description: intl.formatMessage( + user + ? messages.userissuecommentDescription + : messages.issuecommentDescription + ), + value: Notification.ISSUE_COMMENT, + hasNotifyUser: true, + }, + { + id: 'issue-resolved', + name: intl.formatMessage(messages.issueresolved), + description: intl.formatMessage( + user + ? messages.userissueresolvedDescription + : messages.issueresolvedDescription + ), + value: Notification.ISSUE_RESOLVED, + hasNotifyUser: true, + }, ]; const filteredTypes = types.filter( diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index 71c6fc8b5..b4b738250 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -49,6 +49,12 @@ export const messages = defineMessages({ 'Grant permission to use advanced request options.', viewrequests: 'View Requests', viewrequestsDescription: "Grant permission to view other users' requests.", + manageissues: 'Manage Issues', + manageissuesDescription: 'Grant permission to manage Overseerr issues.', + createissues: 'Create Issues', + createissuesDescription: 'Grant permission to create new issues.', + viewissues: 'View Issues', + viewissuesDescription: "Grant permission to view other users' issues.", }); interface PermissionEditProps { @@ -223,6 +229,26 @@ export const PermissionEdit: React.FC = ({ }, ], }, + { + id: 'manageissues', + name: intl.formatMessage(messages.manageissues), + description: intl.formatMessage(messages.manageissuesDescription), + permission: Permission.MANAGE_ISSUES, + children: [ + { + id: 'createissues', + name: intl.formatMessage(messages.createissues), + description: intl.formatMessage(messages.createissuesDescription), + permission: Permission.CREATE_ISSUES, + }, + { + id: 'viewissues', + name: intl.formatMessage(messages.viewissues), + description: intl.formatMessage(messages.viewissuesDescription), + permission: Permission.VIEW_ISSUES, + }, + ], + }, ]; return ( diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index a3a203e7f..c625598b9 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -104,7 +104,7 @@ const RequestItem: React.FC = ({ ? `/api/v1/movie/${request.media.tmdbId}` : `/api/v1/tv/${request.media.tmdbId}`; const { data: title, error } = useSWR( - inView ? `${url}` : null + inView ? url : null ); const { data: requestData, diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 6ccc60499..8ff39fb68 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -1,15 +1,10 @@ import { ArrowCircleRightIcon, CogIcon, + ExclamationIcon, FilmIcon, PlayIcon, } from '@heroicons/react/outline'; -import { - CheckCircleIcon, - DocumentRemoveIcon, - ExternalLinkIcon, -} from '@heroicons/react/solid'; -import axios from 'axios'; import Link from 'next/link'; import { useRouter } from 'next/router'; import React, { useMemo, useState } from 'react'; @@ -17,6 +12,7 @@ import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import type { RTRating } from '../../../server/api/rottentomatoes'; import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants'; +import { IssueStatus } from '../../../server/constants/issue'; import { MediaStatus } from '../../../server/constants/media'; import { Crew } from '../../../server/models/common'; import { TvDetails as TvDetailsType } from '../../../server/models/Tv'; @@ -33,16 +29,14 @@ import Error from '../../pages/_error'; import { sortCrewPriority } from '../../utils/creditHelpers'; import Button from '../Common/Button'; import CachedImage from '../Common/CachedImage'; -import ConfirmButton from '../Common/ConfirmButton'; import LoadingSpinner from '../Common/LoadingSpinner'; import PageTitle from '../Common/PageTitle'; import PlayButton, { PlayButtonLink } from '../Common/PlayButton'; -import SlideOver from '../Common/SlideOver'; -import DownloadBlock from '../DownloadBlock'; import ExternalLinkBlock from '../ExternalLinkBlock'; +import IssueModal from '../IssueModal'; +import ManageSlideOver from '../ManageSlideOver'; import MediaSlider from '../MediaSlider'; import PersonCard from '../PersonCard'; -import RequestBlock from '../RequestBlock'; import RequestButton from '../RequestButton'; import RequestModal from '../RequestModal'; import Slider from '../Slider'; @@ -58,25 +52,13 @@ const messages = defineMessages({ similar: 'Similar Series', watchtrailer: 'Watch Trailer', overviewunavailable: 'Overview unavailable.', - manageModalTitle: 'Manage Series', - manageModalRequests: 'Requests', - manageModalNoRequests: 'No requests.', - manageModalClearMedia: 'Clear Media Data', - manageModalClearMediaWarning: - '* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.', originaltitle: 'Original Title', showtype: 'Series Type', anime: 'Anime', network: '{networkCount, plural, one {Network} other {Networks}}', viewfullcrew: 'View Full Crew', - opensonarr: 'Open Series in Sonarr', - opensonarr4k: 'Open Series in 4K Sonarr', - downloadstatus: 'Download Status', playonplex: 'Play on Plex', play4konplex: 'Play in 4K on Plex', - markavailable: 'Mark as Available', - mark4kavailable: 'Mark as Available in 4K', - allseasonsmarkedavailable: '* All seasons will be marked as available.', seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}', episodeRuntime: 'Episode Runtime', episodeRuntimeMinutes: '{runtime} minutes', @@ -95,6 +77,7 @@ const TvDetails: React.FC = ({ tv }) => { const { locale } = useLocale(); const [showRequestModal, setShowRequestModal] = useState(false); const [showManager, setShowManager] = useState(false); + const [showIssueModal, setShowIssueModal] = useState(false); const { data, error, revalidate } = useSWR( `/api/v1/tv/${router.query.tvId}`, @@ -156,20 +139,6 @@ const TvDetails: React.FC = ({ tv }) => { }); } - const deleteMedia = async () => { - if (data?.mediaInfo?.id) { - await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); - revalidate(); - } - }; - - const markAvailable = async (is4k = false) => { - await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, { - is4k, - }); - revalidate(); - }; - const region = user?.settings?.region ? user.settings.region : settings.currentSettings.region @@ -261,6 +230,12 @@ const TvDetails: React.FC = ({ tv }) => {
)} + setShowIssueModal(false)} + show={showIssueModal} + mediaType="tv" + tmdbId={data.id} + /> = ({ tv }) => { }} onCancel={() => setShowRequestModal(false)} /> - setShowManager(false)} - subText={data.name} - > - {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || - (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( - <> -

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

-
-
    - {data.mediaInfo?.downloadStatus?.map((status, index) => ( -
  • - -
  • - ))} - {data.mediaInfo?.downloadStatus4k?.map((status, index) => ( -
  • - -
  • - ))} -
-
- - )} - {data?.mediaInfo && - (data.mediaInfo.status !== MediaStatus.AVAILABLE || - (data.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled)) && ( -
- {data?.mediaInfo && - data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( -
- -
- )} - {data?.mediaInfo && - data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( -
- -
- )} -
- {intl.formatMessage(messages.allseasonsmarkedavailable)} -
-
- )} -

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

-
-
    - {data.mediaInfo?.requests?.map((request) => ( -
  • - revalidate()} /> -
  • - ))} - {(data.mediaInfo?.requests ?? []).length === 0 && ( -
  • - {intl.formatMessage(messages.manageModalNoRequests)} -
  • - )} -
-
- {(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( -
- {data?.mediaInfo?.serviceUrl && ( - - - - )} - {data?.mediaInfo?.serviceUrl4k && ( - - - - )} -
- )} - {data?.mediaInfo && ( -
- deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.manageModalClearMedia)} - -
- {intl.formatMessage(messages.manageModalClearMediaWarning)} -
-
- )} -
+ revalidate={() => revalidate()} + show={showManager} + />
= ({ tv }) => { .map((t, k) => {t}) .reduce((prev, curr) => ( <> - {prev} | {curr} + {prev} + | + {curr} ))} @@ -484,13 +330,41 @@ const TvDetails: React.FC = ({ tv }) => { isShowComplete={isComplete} is4kShowComplete={is4kComplete} /> + {(data.mediaInfo?.status === MediaStatus.AVAILABLE || + data.mediaInfo?.status4k === MediaStatus.AVAILABLE || + data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE || + data?.mediaInfo?.status4k === MediaStatus.PARTIALLY_AVAILABLE) && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { + type: 'or', + } + ) && ( + + )} {hasPermission(Permission.MANAGE_REQUESTS) && ( )}
diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 4c0ef7905..34a2b7101 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -49,6 +49,8 @@ const globalMessages = defineMessages({ 'Showing {from} to {to} of {total} results', resultsperpage: 'Display {pageSize} results per page', noresults: 'No results.', + open: 'Open', + resolved: 'Resolved', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 789d05a7e..6abd1ba33 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -32,11 +32,88 @@ "components.Discover.upcomingmovies": "Upcoming Movies", "components.Discover.upcomingtv": "Upcoming Series", "components.DownloadBlock.estimatedtime": "Estimated {time}", + "components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?", + "components.IssueDetails.IssueComment.delete": "Delete Comment", + "components.IssueDetails.IssueComment.edit": "Edit Comment", + "components.IssueDetails.IssueComment.postedby": "Posted by {username} {relativeTime}", + "components.IssueDetails.IssueComment.postedbyedited": "Posted by {username} {relativeTime} (Edited)", + "components.IssueDetails.IssueComment.validationComment": "You must provide a message", + "components.IssueDetails.IssueDescription.cancel": "Cancel", + "components.IssueDetails.IssueDescription.deleteissue": "Delete Issue", + "components.IssueDetails.IssueDescription.description": "Description", + "components.IssueDetails.IssueDescription.edit": "Edit Description", + "components.IssueDetails.IssueDescription.save": "Save Changes", + "components.IssueDetails.allepisodes": "All Episodes", + "components.IssueDetails.allseasons": "All Seasons", + "components.IssueDetails.closeissue": "Close Issue", + "components.IssueDetails.closeissueandcomment": "Close with Comment", + "components.IssueDetails.comments": "Comments", + "components.IssueDetails.deleteissue": "Delete Issue", + "components.IssueDetails.deleteissueconfirm": "Are you sure you want to delete this issue?", + "components.IssueDetails.episode": "Episode {episodeNumber}", + "components.IssueDetails.issuepagetitle": "Issue", + "components.IssueDetails.issuetype": "Issue Type", + "components.IssueDetails.lastupdated": "Last Updated", + "components.IssueDetails.leavecomment": "Comment", + "components.IssueDetails.mediatype": "Media Type", + "components.IssueDetails.nocomments": "No comments.", + "components.IssueDetails.openedby": "#{issueId} opened {relativeTime} by {username}", + "components.IssueDetails.openinradarr": "Open in Radarr", + "components.IssueDetails.openinsonarr": "Open in Sonarr", + "components.IssueDetails.problemepisode": "Affected Episode", + "components.IssueDetails.problemseason": "Affected Season", + "components.IssueDetails.reopenissue": "Reopen Issue", + "components.IssueDetails.reopenissueandcomment": "Reopen with Comment", + "components.IssueDetails.season": "Season {seasonNumber}", + "components.IssueDetails.statusopen": "Open", + "components.IssueDetails.statusresolved": "Resolved", + "components.IssueDetails.toasteditdescriptionfailed": "Something went wrong editing the description.", + "components.IssueDetails.toasteditdescriptionsuccess": "Successfully edited the issue description.", + "components.IssueDetails.toastissuedeleted": "Issue deleted succesfully.", + "components.IssueDetails.toastissuedeletefailed": "Something went wrong deleting the issue.", + "components.IssueDetails.toaststatusupdated": "Issue status updated.", + "components.IssueDetails.toaststatusupdatefailed": "Something went wrong updating the issue status.", + "components.IssueDetails.unknownissuetype": "Unknown", + "components.IssueList.IssueItem.allepisodes": "All Episodes", + "components.IssueList.IssueItem.allseasons": "All Seasons", + "components.IssueList.IssueItem.episode": "Episode {episodeNumber}", + "components.IssueList.IssueItem.issuestatus": "Status", + "components.IssueList.IssueItem.issuetype": "Type", + "components.IssueList.IssueItem.opened": "Opened", + "components.IssueList.IssueItem.openeduserdate": "{date} by {user}", + "components.IssueList.IssueItem.problemepisode": "Affected Episode", + "components.IssueList.IssueItem.season": "Season {seasonNumber}", + "components.IssueList.IssueItem.unknownissuetype": "Unknown", + "components.IssueList.IssueItem.viewissue": "View Issue", + "components.IssueList.issues": "Issues", + "components.IssueList.showallissues": "Show All Issues", + "components.IssueList.sortAdded": "Request Date", + "components.IssueList.sortModified": "Last Modified", + "components.IssueModal.CreateIssueModal.allepisodes": "All Episodes", + "components.IssueModal.CreateIssueModal.allseasons": "All Seasons", + "components.IssueModal.CreateIssueModal.episode": "Episode {episodeNumber}", + "components.IssueModal.CreateIssueModal.issomethingwrong": "Is there a problem with {title}?", + "components.IssueModal.CreateIssueModal.problemepisode": "Affected Episode", + "components.IssueModal.CreateIssueModal.problemseason": "Affected Season", + "components.IssueModal.CreateIssueModal.providedetail": "Provide a detailed explanation of the issue.", + "components.IssueModal.CreateIssueModal.reportissue": "Report an Issue", + "components.IssueModal.CreateIssueModal.season": "Season {seasonNumber}", + "components.IssueModal.CreateIssueModal.submitissue": "Submit Issue", + "components.IssueModal.CreateIssueModal.toastFailedCreate": "Something went wrong while submitting the issue.", + "components.IssueModal.CreateIssueModal.toastSuccessCreate": "Issue report for {title} submitted successfully!", + "components.IssueModal.CreateIssueModal.toastviewissue": "View Issue", + "components.IssueModal.CreateIssueModal.validationMessageRequired": "You must provide a description", + "components.IssueModal.CreateIssueModal.whatswrong": "What's wrong?", + "components.IssueModal.issueAudio": "Audio", + "components.IssueModal.issueOther": "Other", + "components.IssueModal.issueSubtitles": "Subtitles", + "components.IssueModal.issueVideo": "Video", "components.LanguageSelector.languageServerDefault": "Default ({language})", "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", "components.Layout.Sidebar.dashboard": "Discover", + "components.Layout.Sidebar.issues": "Issues", "components.Layout.Sidebar.requests": "Requests", "components.Layout.Sidebar.settings": "Settings", "components.Layout.Sidebar.users": "Users", @@ -58,21 +135,26 @@ "components.Login.signinwithplex": "Use your Plex account", "components.Login.validationemailrequired": "You must provide a valid email address", "components.Login.validationpasswordrequired": "You must provide a password", + "components.ManageSlideOver.allseasonsmarkedavailable": "* All seasons will be marked as available.", + "components.ManageSlideOver.downloadstatus": "Download Status", + "components.ManageSlideOver.manageModalClearMedia": "Clear Media Data", + "components.ManageSlideOver.manageModalClearMediaWarning": "* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", + "components.ManageSlideOver.manageModalNoRequests": "No requests.", + "components.ManageSlideOver.manageModalRequests": "Requests", + "components.ManageSlideOver.manageModalTitle": "Manage {mediaType}", + "components.ManageSlideOver.mark4kavailable": "Mark as Available in 4K", + "components.ManageSlideOver.markavailable": "Mark as Available", + "components.ManageSlideOver.movie": "movie", + "components.ManageSlideOver.openarr": "Open {mediaType} in {arr}", + "components.ManageSlideOver.openarr4k": "Open {mediaType} in 4K {arr}", + "components.ManageSlideOver.tvshow": "series", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.budget": "Budget", "components.MovieDetails.cast": "Cast", - "components.MovieDetails.downloadstatus": "Download Status", - "components.MovieDetails.manageModalClearMedia": "Clear Media Data", - "components.MovieDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", - "components.MovieDetails.manageModalNoRequests": "No requests.", - "components.MovieDetails.manageModalRequests": "Requests", - "components.MovieDetails.manageModalTitle": "Manage Movie", "components.MovieDetails.mark4kavailable": "Mark as Available in 4K", "components.MovieDetails.markavailable": "Mark as Available", - "components.MovieDetails.openradarr": "Open Movie in Radarr", - "components.MovieDetails.openradarr4k": "Open Movie in 4K Radarr", "components.MovieDetails.originallanguage": "Original Language", "components.MovieDetails.originaltitle": "Original Title", "components.MovieDetails.overview": "Overview", @@ -90,6 +172,12 @@ "components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}", "components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.watchtrailer": "Watch Trailer", + "components.NotificationTypeSelector.issuecomment": "Issue Comment", + "components.NotificationTypeSelector.issuecommentDescription": "Send notifications when issues receive new comments.", + "components.NotificationTypeSelector.issuecreated": "Issue Created", + "components.NotificationTypeSelector.issuecreatedDescription": "Send notifications when new issues are created.", + "components.NotificationTypeSelector.issueresolved": "Issue Resolved", + "components.NotificationTypeSelector.issueresolvedDescription": "Send notifications when issues are resolved.", "components.NotificationTypeSelector.mediaAutoApproved": "Media Automatically Approved", "components.NotificationTypeSelector.mediaAutoApprovedDescription": "Send notifications when users submit new media requests which are automatically approved.", "components.NotificationTypeSelector.mediaapproved": "Media Approved", @@ -103,6 +191,8 @@ "components.NotificationTypeSelector.mediarequested": "Media Requested", "components.NotificationTypeSelector.mediarequestedDescription": "Send notifications when users submit new media requests which require approval.", "components.NotificationTypeSelector.notificationTypes": "Notification Types", + "components.NotificationTypeSelector.userissuecommentDescription": "Send notifications when your issue receives new comments.", + "components.NotificationTypeSelector.userissueresolvedDescription": "Send notifications when your issues are resolved.", "components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.", "components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.", "components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.", @@ -125,6 +215,10 @@ "components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests.", "components.PermissionEdit.autoapproveSeries": "Auto-Approve Series", "components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.", + "components.PermissionEdit.createissues": "Create Issues", + "components.PermissionEdit.createissuesDescription": "Grant permission to create new issues.", + "components.PermissionEdit.manageissues": "Manage Issues", + "components.PermissionEdit.manageissuesDescription": "Grant permission to manage Overseerr issues.", "components.PermissionEdit.managerequests": "Manage Requests", "components.PermissionEdit.managerequestsDescription": "Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.", "components.PermissionEdit.request": "Request", @@ -143,6 +237,8 @@ "components.PermissionEdit.settingsDescription": "Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.", "components.PermissionEdit.users": "Manage Users", "components.PermissionEdit.usersDescription": "Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.", + "components.PermissionEdit.viewissues": "View Issues", + "components.PermissionEdit.viewissuesDescription": "Grant permission to view other users' issues.", "components.PermissionEdit.viewrequests": "View Requests", "components.PermissionEdit.viewrequestsDescription": "Grant permission to view other users' requests.", "components.PersonDetails.alsoknownas": "Also Known As: {names}", @@ -680,24 +776,13 @@ "components.StatusChacker.reloadOverseerr": "Reload", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", - "components.TvDetails.allseasonsmarkedavailable": "* All seasons will be marked as available.", "components.TvDetails.anime": "Anime", "components.TvDetails.cast": "Cast", - "components.TvDetails.downloadstatus": "Download Status", "components.TvDetails.episodeRuntime": "Episode Runtime", "components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes", "components.TvDetails.firstAirDate": "First Air Date", - "components.TvDetails.manageModalClearMedia": "Clear Media Data", - "components.TvDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.", - "components.TvDetails.manageModalNoRequests": "No requests.", - "components.TvDetails.manageModalRequests": "Requests", - "components.TvDetails.manageModalTitle": "Manage Series", - "components.TvDetails.mark4kavailable": "Mark as Available in 4K", - "components.TvDetails.markavailable": "Mark as Available", "components.TvDetails.network": "{networkCount, plural, one {Network} other {Networks}}", "components.TvDetails.nextAirDate": "Next Air Date", - "components.TvDetails.opensonarr": "Open Series in Sonarr", - "components.TvDetails.opensonarr4k": "Open Series in 4K Sonarr", "components.TvDetails.originallanguage": "Original Language", "components.TvDetails.originaltitle": "Original Title", "components.TvDetails.overview": "Overview", @@ -859,6 +944,7 @@ "i18n.next": "Next", "i18n.noresults": "No results.", "i18n.notrequested": "Not Requested", + "i18n.open": "Open", "i18n.partiallyavailable": "Partially Available", "i18n.pending": "Pending", "i18n.previous": "Previous", @@ -867,6 +953,7 @@ "i18n.request4k": "Request in 4K", "i18n.requested": "Requested", "i18n.requesting": "Requesting…", + "i18n.resolved": "Resolved", "i18n.resultsperpage": "Display {pageSize} results per page", "i18n.retry": "Retry", "i18n.retrying": "Retrying…", diff --git a/src/pages/issues/[issueId]/index.tsx b/src/pages/issues/[issueId]/index.tsx new file mode 100644 index 000000000..730bee6e7 --- /dev/null +++ b/src/pages/issues/[issueId]/index.tsx @@ -0,0 +1,9 @@ +import { NextPage } from 'next'; +import React from 'react'; +import IssueDetails from '../../../components/IssueDetails'; + +const IssuePage: NextPage = () => { + return ; +}; + +export default IssuePage; diff --git a/src/pages/issues/index.tsx b/src/pages/issues/index.tsx new file mode 100644 index 000000000..0168d888f --- /dev/null +++ b/src/pages/issues/index.tsx @@ -0,0 +1,9 @@ +import { NextPage } from 'next'; +import React from 'react'; +import IssueList from '../../components/IssueList'; + +const IssuePage: NextPage = () => { + return ; +}; + +export default IssuePage; diff --git a/src/styles/globals.css b/src/styles/globals.css index fec0c7127..228ec9c6f 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -2,457 +2,463 @@ @tailwind components; @tailwind utilities; -html { - min-height: calc(100% + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) env(safe-area-inset-right) - env(safe-area-inset-bottom) env(safe-area-inset-left); -} - -body { - @apply bg-gray-900; -} - -.searchbar { - padding-top: env(safe-area-inset-top); - height: calc(4rem + env(safe-area-inset-top)); -} - -.sidebar { - @apply border-r border-gray-700; - padding-top: env(safe-area-inset-top); - padding-left: env(safe-area-inset-left); - background: linear-gradient(180deg, rgba(31, 41, 55, 1) 0%, #131928 100%); -} - -.slideover { - padding-top: calc(1.5rem + env(safe-area-inset-top)); - padding-bottom: 1.5rem; -} - -.sidebar-close-button { - top: env(safe-area-inset-top); -} - -.absolute-top-shift { - top: calc(-4rem - env(safe-area-inset-top)); -} - -.min-h-screen-shift { - min-height: calc(100vh + env(safe-area-inset-top)); -} - -.plex-button { - @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; -} - -.plex-button:hover { - background: #f19a30; -} - -ul.cards-vertical, -ul.cards-horizontal { - @apply grid gap-4; -} - -ul.cards-vertical { - grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); -} - -ul.cards-horizontal { - grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); -} - -.slider-header { - @apply relative flex mt-6 mb-4; -} - -.slider-title { - @apply inline-flex items-center text-xl font-bold leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate; -} - -a.slider-title { - @apply transition duration-300 hover:text-white; -} - -a.slider-title svg { - @apply w-6 h-6 ml-2; -} - -.media-page { - @apply relative px-4 -mx-4 bg-center bg-cover; - margin-top: calc(-4rem - env(safe-area-inset-top)); - padding-top: calc(4rem + env(safe-area-inset-top)); -} - -.media-page-bg-image { - @apply absolute inset-0 w-full h-full; - z-index: -10; -} - -.media-header { - @apply flex flex-col items-center pt-4 xl:flex-row xl:items-end; -} - -.media-poster { - @apply w-32 overflow-hidden rounded shadow md:rounded-lg md:shadow-2xl md:w-44 xl:w-52 xl:mr-4; -} - -.media-status { - @apply mb-2 space-x-2; -} - -.media-title { - @apply flex flex-col flex-1 mt-4 text-center text-white xl:mr-4 xl:mt-0 xl:text-left; -} - -.media-title > h1 { - @apply text-2xl font-bold xl:text-4xl; -} - -h1 > .media-year { - @apply text-2xl; -} - -.media-attributes { - @apply mt-1 text-xs text-gray-300 sm:text-sm xl:text-base xl:mt-0; -} - -.media-attributes a { - @apply transition duration-300 hover:text-white hover:underline; -} - -.media-actions { - @apply relative flex flex-wrap items-center justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap xl:mt-0; -} - -.media-actions > * { - @apply mb-3 sm:mb-0; -} - -.media-overview { - @apply flex flex-col pt-8 pb-4 text-white lg:flex-row; -} - -.media-overview-left { - @apply flex-1 lg:mr-8; -} - -.tagline { - @apply mb-4 text-xl italic text-gray-400 lg:text-2xl; -} - -.media-overview h2 { - @apply text-xl font-bold text-gray-300 sm:text-2xl; -} - -.media-overview p { - @apply pt-2 text-sm text-gray-400 sm:text-base; -} - -ul.media-crew { - @apply grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3; -} - -ul.media-crew > li { - @apply flex flex-col col-span-1 font-bold text-gray-300; -} - -a.crew-name, -.media-fact-value a, -.media-fact-value button { - @apply font-normal text-gray-400 transition duration-300 hover:underline hover:text-gray-100; -} - -.media-overview-right { - @apply w-full mt-8 lg:w-80 lg:mt-0; -} - -.media-facts { - @apply text-sm font-bold text-gray-300 bg-gray-900 border border-gray-700 rounded-lg shadow; -} - -.media-fact { - @apply flex justify-between px-4 py-2 border-b border-gray-700 last:border-b-0; -} - -.media-fact-value { - @apply ml-2 text-sm font-normal text-right text-gray-400; -} - -.media-ratings { - @apply flex items-center justify-center px-4 py-2 font-medium border-b border-gray-700 last:border-b-0; -} - -.media-rating { - @apply flex items-center mr-4 last:mr-0; -} - -.error-message { - @apply relative top-0 bottom-0 left-0 right-0 flex flex-col items-center justify-center h-screen text-center text-gray-300; -} - -.heading { - @apply text-2xl font-bold leading-8 text-gray-100; -} - -.description { - @apply max-w-4xl mt-1 text-sm leading-5 text-gray-400; -} - -img.avatar-sm { - @apply w-5 h-5 mr-1.5 rounded-full transition duration-300 scale-100 transform-gpu group-hover:scale-105; -} - -.card-field { - @apply flex items-center py-0.5 sm:py-1 text-sm truncate; -} - -.card-field-name { - @apply mr-2 font-bold; -} - -.card-field a { - @apply transition duration-300 hover:text-white hover:underline; -} - -.section { - @apply mt-6 mb-10 text-white; -} - -.form-row { - @apply max-w-6xl mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start; -} - -.form-input { - @apply text-sm text-white sm:col-span-2; -} - -.form-input-field { - @apply flex max-w-xl rounded-md shadow-sm; -} - -.actions { - @apply pt-5 mt-8 text-white border-t border-gray-700; -} - -label, -.group-label { - @apply block mb-1 text-sm font-bold leading-5 text-gray-400; -} - -label.checkbox-label { - @apply sm:mt-1; -} - -label.text-label { - @apply sm:mt-2; -} - -label a { - @apply text-gray-100 transition duration-300 hover:text-white hover:underline; -} - -.label-required { - @apply ml-1 text-red-500; -} - -.label-tip { - @apply block font-medium text-gray-500; -} - -button, -input, -select, -textarea { - @apply disabled:cursor-not-allowed; -} - -input[type='checkbox'] { - @apply w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md; -} - -input[type='text'], -input[type='password'], -select, -textarea { - @apply flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md sm:text-sm sm:leading-5; -} - -input.rounded-l-only, -select.rounded-l-only, -textarea.rounded-l-only { - @apply rounded-r-none; -} - -input.rounded-r-only, -select.rounded-r-only, -textarea.rounded-r-only { - @apply rounded-l-none; -} - -input.short { - @apply w-20; -} - -select.short { - @apply w-min; -} - -button > span { - @apply whitespace-nowrap; -} - -button.input-action { - @apply relative inline-flex items-center px-3 sm:px-3.5 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-gray-500 hover:bg-indigo-500 active:bg-gray-100 active:text-gray-700 last:rounded-r-md; -} - -.button-md svg, -button.input-action svg, -.plex-button svg { - @apply w-5 h-5 ml-2 mr-2 first:ml-0 last:mr-0; -} - -.button-sm svg { - @apply w-4 h-4 ml-1.5 mr-1.5 first:ml-0 last:mr-0; -} - -.modal-icon { - @apply flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto text-white bg-gray-800 rounded-full ring-1 ring-gray-500 sm:mx-0 sm:h-10 sm:w-10; -} - -.modal-icon svg { - @apply w-6 h-6; -} - -svg.icon-md { - @apply w-5 h-5; -} - -svg.icon-sm { - @apply w-4 h-4; -} - -.protocol { - @apply inline-flex items-center px-3 text-gray-100 bg-gray-600 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm; -} - -.error { - @apply mt-2 text-sm text-red-500; -} - -.form-group { - @apply mt-6 text-white; -} - -.toast { - width: 360px; -} - -/* Used for animating height */ -.extra-max-height { - max-height: 100rem; -} - -.hide-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} - -.hide-scrollbar::-webkit-scrollbar { - display: none; -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -.hide-scrollbar::-webkit-scrollbar { - display: none; -} - -/* Hide scrollbar for IE, Edge and Firefox */ -.hide-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} - -code { - @apply px-2 py-1 bg-gray-800 rounded-md; -} - -input[type='search']::-webkit-search-cancel-button { - -webkit-appearance: none; -} - -.react-select-container { - @apply w-full; -} - -.react-select-container .react-select__control { - @apply text-white bg-gray-700 border border-gray-500 rounded-md hover:border-gray-500; -} - -.react-select-container-dark .react-select__control { - @apply bg-gray-800 border border-gray-700; -} - -.react-select-container .react-select__control--is-focused { - @apply text-white bg-gray-700 border border-gray-500 rounded-md shadow; -} - -.react-select-container-dark .react-select__control--is-focused { - @apply bg-gray-800 border-gray-600; -} - -.react-select-container .react-select__menu { - @apply text-gray-300 bg-gray-700; -} - -.react-select-container-dark .react-select__menu { - @apply bg-gray-800; -} - -.react-select-container .react-select__option--is-focused { - @apply text-white bg-gray-600; -} - -.react-select-container-dark .react-select__option--is-focused { - @apply bg-gray-700; -} - -.react-select-container .react-select__indicator-separator { - @apply bg-gray-500; -} - -.react-select-container .react-select__indicator { - @apply text-gray-500; -} - -.react-select-container .react-select__placeholder { - @apply text-gray-400; -} - -.react-select-container .react-select__multi-value { - @apply bg-gray-800 border border-gray-500 rounded-md; -} - -.react-select-container .react-select__multi-value__label { - @apply text-white; -} - -.react-select-container .react-select__multi-value__remove { - @apply cursor-pointer rounded-r-md hover:bg-red-700 hover:text-red-100; -} - -.react-select-container .react-select__input { - @apply text-base text-white border-none shadow-sm; -} - -.react-select-container .react-select__input input:focus { - @apply text-white border-none; - box-shadow: none; -} - -@media all and (display-mode: browser) { - .pwa-only { - @apply hidden; +@layer base { + html { + min-height: calc(100% + env(safe-area-inset-top)); + padding: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); + } + + body { + @apply bg-gray-900; + } + + code { + @apply px-2 py-1 bg-gray-800 rounded-md; + } + + input[type='search']::-webkit-search-cancel-button { + -webkit-appearance: none; + } +} + +@layer components { + .searchbar { + padding-top: env(safe-area-inset-top); + height: calc(4rem + env(safe-area-inset-top)); + } + + .sidebar { + @apply border-r border-gray-700; + padding-top: env(safe-area-inset-top); + padding-left: env(safe-area-inset-left); + background: linear-gradient(180deg, rgba(31, 41, 55, 1) 0%, #131928 100%); + } + + .slideover { + padding-top: calc(1.5rem + env(safe-area-inset-top)); + padding-bottom: 1.5rem; + } + + .sidebar-close-button { + top: env(safe-area-inset-top); + } + + .plex-button { + @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; + } + + .plex-button:hover { + background: #f19a30; + } + + ul.cards-vertical, + ul.cards-horizontal { + @apply grid gap-4; + } + + ul.cards-vertical { + grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); + } + + ul.cards-horizontal { + grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); + } + + .slider-header { + @apply relative flex mt-6 mb-4; + } + + .slider-title { + @apply inline-flex items-center text-xl font-bold leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate; + } + + a.slider-title { + @apply transition duration-300 hover:text-white; + } + + a.slider-title svg { + @apply w-6 h-6 ml-2; + } + + .media-page { + @apply relative px-4 -mx-4 bg-center bg-cover; + margin-top: calc(-4rem - env(safe-area-inset-top)); + padding-top: calc(4rem + env(safe-area-inset-top)); + } + + .media-page-bg-image { + @apply absolute inset-0 w-full h-full; + z-index: -10; + } + + .media-header { + @apply flex flex-col items-center pt-4 xl:flex-row xl:items-end; + } + + .media-poster { + @apply w-32 overflow-hidden rounded shadow md:rounded-lg md:shadow-2xl md:w-44 xl:w-52 xl:mr-4; + } + + .media-status { + @apply mb-2 space-x-2; + } + + .media-title { + @apply flex flex-col flex-1 mt-4 text-center text-white xl:mr-4 xl:mt-0 xl:text-left; + } + + .media-title > h1 { + @apply text-2xl font-bold xl:text-4xl; + } + + h1 .media-year { + @apply text-2xl; + } + + .media-attributes { + @apply flex items-center mt-1 space-x-1 text-xs text-gray-300 sm:text-sm xl:text-base xl:mt-0; + } + + .media-attributes a { + @apply transition duration-300 hover:text-white hover:underline; + } + + .media-actions { + @apply relative flex flex-wrap items-center justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap xl:mt-0; + } + + .media-actions > * { + @apply mb-3 sm:mb-0; + } + + .media-overview { + @apply flex flex-col pt-8 pb-4 text-white lg:flex-row; + } + + .media-overview-left { + @apply flex-1 lg:mr-8; + } + + .tagline { + @apply mb-4 text-xl italic text-gray-400 lg:text-2xl; + } + + .media-overview h2 { + @apply text-xl font-bold text-gray-300 sm:text-2xl; + } + + .media-overview p { + @apply pt-2 text-sm text-gray-400 sm:text-base; + } + + ul.media-crew { + @apply grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3; + } + + ul.media-crew > li { + @apply flex flex-col col-span-1 font-bold text-gray-300; + } + + a.crew-name, + .media-fact-value a, + .media-fact-value button { + @apply font-normal text-gray-400 transition duration-300 hover:underline hover:text-gray-100; + } + + .media-overview-right { + @apply w-full mt-8 lg:w-80 lg:mt-0; + } + + .media-facts { + @apply text-sm font-bold text-gray-300 bg-gray-900 border border-gray-700 rounded-lg shadow; + } + + .media-fact { + @apply flex justify-between px-4 py-2 border-b border-gray-700 last:border-b-0; + } + + .media-fact-value { + @apply ml-2 text-sm font-normal text-right text-gray-400; + } + + .media-ratings { + @apply flex items-center justify-center px-4 py-2 font-medium border-b border-gray-700 last:border-b-0; + } + + .media-rating { + @apply flex items-center mr-4 last:mr-0; + } + + .error-message { + @apply relative top-0 bottom-0 left-0 right-0 flex flex-col items-center justify-center h-screen text-center text-gray-300; + } + + .heading { + @apply text-2xl font-bold leading-8 text-gray-100; + } + + .description { + @apply max-w-4xl mt-1 text-sm leading-5 text-gray-400; + } + + img.avatar-sm { + @apply w-5 h-5 mr-1.5 rounded-full transition duration-300 scale-100 transform-gpu group-hover:scale-105; + } + + .card-field { + @apply flex items-center py-0.5 sm:py-1 text-sm truncate; + } + + .card-field-name { + @apply mr-2 font-bold; + } + + .card-field a { + @apply transition duration-300 hover:text-white hover:underline; + } + + .section { + @apply mt-6 mb-10 text-white; + } + + .form-row { + @apply max-w-6xl mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start; + } + + .form-input { + @apply text-sm text-white sm:col-span-2; + } + + .form-input-field { + @apply flex max-w-xl rounded-md shadow-sm; + } + + .actions { + @apply pt-5 mt-8 text-white border-t border-gray-700; + } + + label, + .group-label { + @apply block mb-1 text-sm font-bold leading-5 text-gray-400; + } + + label.checkbox-label { + @apply sm:mt-1; + } + + label.text-label { + @apply sm:mt-2; + } + + label a { + @apply text-gray-100 transition duration-300 hover:text-white hover:underline; + } + + .label-required { + @apply ml-1 text-red-500; + } + + .label-tip { + @apply block font-medium text-gray-500; + } + + button, + input, + select, + textarea { + @apply disabled:cursor-not-allowed; + } + + input[type='checkbox'] { + @apply w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md; + } + + input[type='text'], + input[type='password'], + select, + textarea { + @apply flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md sm:text-sm sm:leading-5; + } + + input.rounded-l-only, + select.rounded-l-only, + textarea.rounded-l-only { + @apply rounded-r-none; + } + + input.rounded-r-only, + select.rounded-r-only, + textarea.rounded-r-only { + @apply rounded-l-none; + } + + input.short { + @apply w-20; + } + + select.short { + @apply w-min; + } + + button > span { + @apply whitespace-nowrap; + } + + button.input-action { + @apply relative inline-flex items-center px-3 sm:px-3.5 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-gray-500 hover:bg-indigo-500 active:bg-gray-100 active:text-gray-700 last:rounded-r-md; + } + + .button-md svg, + button.input-action svg, + .plex-button svg { + @apply w-5 h-5 ml-2 mr-2 first:ml-0 last:mr-0; + } + + .button-sm svg { + @apply w-4 h-4 ml-1.5 mr-1.5 first:ml-0 last:mr-0; + } + + .modal-icon { + @apply flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto text-white bg-gray-800 rounded-full ring-1 ring-gray-500 sm:mx-0 sm:h-10 sm:w-10; + } + + .modal-icon svg { + @apply w-6 h-6; + } + + svg.icon-md { + @apply w-5 h-5; + } + + svg.icon-sm { + @apply w-4 h-4; + } + + .protocol { + @apply inline-flex items-center px-3 text-gray-100 bg-gray-600 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm; + } + + .error { + @apply mt-2 text-sm text-red-500; + } + + .form-group { + @apply mt-6 text-white; + } + + .toast { + width: 360px; + } + + .react-select-container { + @apply w-full; + } + + .react-select-container .react-select__control { + @apply text-white bg-gray-700 border border-gray-500 rounded-md hover:border-gray-500; + } + + .react-select-container-dark .react-select__control { + @apply bg-gray-800 border border-gray-700; + } + + .react-select-container .react-select__control--is-focused { + @apply text-white bg-gray-700 border border-gray-500 rounded-md shadow; + } + + .react-select-container-dark .react-select__control--is-focused { + @apply bg-gray-800 border-gray-600; + } + + .react-select-container .react-select__menu { + @apply text-gray-300 bg-gray-700; + } + + .react-select-container-dark .react-select__menu { + @apply bg-gray-800; + } + + .react-select-container .react-select__option--is-focused { + @apply text-white bg-gray-600; + } + + .react-select-container-dark .react-select__option--is-focused { + @apply bg-gray-700; + } + + .react-select-container .react-select__indicator-separator { + @apply bg-gray-500; + } + + .react-select-container .react-select__indicator { + @apply text-gray-500; + } + + .react-select-container .react-select__placeholder { + @apply text-gray-400; + } + + .react-select-container .react-select__multi-value { + @apply bg-gray-800 border border-gray-500 rounded-md; + } + + .react-select-container .react-select__multi-value__label { + @apply text-white; + } + + .react-select-container .react-select__multi-value__remove { + @apply cursor-pointer rounded-r-md hover:bg-red-700 hover:text-red-100; + } + + .react-select-container .react-select__input { + @apply text-base text-white border-none shadow-sm; + } + + .react-select-container .react-select__input input:focus { + @apply text-white border-none; + box-shadow: none; + } +} + +@layer utilities { + .absolute-top-shift { + top: calc(-4rem - env(safe-area-inset-top)); + } + + .min-h-screen-shift { + min-height: calc(100vh + env(safe-area-inset-top)); + } + + /* Used for animating height */ + .extra-max-height { + max-height: 100rem; + } + + .hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .hide-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for Chrome, Safari and Opera */ + .hide-scrollbar::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for IE, Edge and Firefox */ + .hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + @media all and (display-mode: browser) { + .pwa-only { + @apply hidden; + } } } diff --git a/stylelint.config.js b/stylelint.config.js index 79a284598..7339dadef 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -10,6 +10,7 @@ module.exports = { 'variants', 'responsive', 'screen', + 'layer', ], }, ], From a35209c7390a3e7b8383e244bfc9ede591d152fb Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 24 Oct 2021 11:42:52 -0400 Subject: [PATCH 05/53] chore(github): add stalebot-exempt labels (#2225) [skip ci] * chore(github): add stalebot-exempt labels * chore(github): also never stale priority:medium --- .github/stale.yml | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index eeed081e3..a0c96e871 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,18 +1,44 @@ -# Number of days of inactivity before an issue becomes stale +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. daysUntilClose: 7 -# Issues with these labels will never be considered stale + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: - pinned - security - dependencies -# Label to use when marking an issue as stale + - never-stale + - priority:high + - priority:medium + +# Label to use when marking as stale staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable + +# Comment to post when marking as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +# closeComment: > +# Your comment here. + +# Limit to only `issues` or `pulls` +# only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +pulls: + markComment: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. From ab20c21184639e1c7725f7cae96249c6fa157351 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 24 Oct 2021 12:08:03 -0400 Subject: [PATCH 06/53] fix(lang): string edits (#2229) * fix(lang): string edits * fix: correct notif type ordering * fix(lang): a few more edits * fix(frontend): align & wrap media attributes properly * fix(lang): use consistent cache names --- server/lib/cache.ts | 4 +- .../IssueDetails/IssueDescription/index.tsx | 7 +- src/components/IssueDetails/index.tsx | 46 +++++++------ src/components/ManageSlideOver/index.tsx | 4 +- .../NotificationTypeSelector/index.tsx | 31 ++++++--- src/components/PermissionEdit/index.tsx | 41 +++++++----- src/i18n/locale/en.json | 65 +++++++++---------- src/styles/globals.css | 2 +- 8 files changed, 108 insertions(+), 92 deletions(-) diff --git a/server/lib/cache.ts b/server/lib/cache.ts index fa03783c8..7782a05a8 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -40,7 +40,7 @@ class Cache { class CacheManager { private availableCaches: Record = { - tmdb: new Cache('tmdb', 'TMDb API', { + tmdb: new Cache('tmdb', 'The Movie Database API', { stdTtl: 21600, checkPeriod: 60 * 30, }), @@ -54,7 +54,7 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), - plexguid: new Cache('plexguid', 'Plex GUID Cache', { + plexguid: new Cache('plexguid', 'Plex GUID', { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60 * 30, }), diff --git a/src/components/IssueDetails/IssueDescription/index.tsx b/src/components/IssueDetails/IssueDescription/index.tsx index ba550afbd..fea8b3b23 100644 --- a/src/components/IssueDetails/IssueDescription/index.tsx +++ b/src/components/IssueDetails/IssueDescription/index.tsx @@ -5,13 +5,12 @@ import React, { Fragment, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import ReactMarkdown from 'react-markdown'; import { Permission, useUser } from '../../../hooks/useUser'; +import globalMessages from '../../../i18n/globalMessages'; import Button from '../../Common/Button'; const messages = defineMessages({ description: 'Description', edit: 'Edit Description', - cancel: 'Cancel', - save: 'Save Changes', deleteissue: 'Delete Issue', }); @@ -125,10 +124,10 @@ const IssueDescription: React.FC = ({ type="button" onClick={() => setIsEditing(false)} > - {intl.formatMessage(messages.cancel)} + {intl.formatMessage(globalMessages.cancel)}
diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index 46ba759e8..9643d38e2 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -43,12 +43,13 @@ const messages = defineMessages({ reopenissue: 'Reopen Issue', reopenissueandcomment: 'Reopen with Comment', issuepagetitle: 'Issue', - openinradarr: 'Open in Radarr', - openinsonarr: 'Open in Sonarr', - toasteditdescriptionsuccess: 'Successfully edited the issue description.', - toasteditdescriptionfailed: 'Something went wrong editing the description.', - toaststatusupdated: 'Issue status updated.', - toaststatusupdatefailed: 'Something went wrong updating the issue status.', + openinarr: 'Open in {arr}', + toasteditdescriptionsuccess: 'Edited the issue description successfully!', + toasteditdescriptionfailed: + 'Something went wrong while editing the issue description.', + toaststatusupdated: 'Updated the issue status successfully!', + toaststatusupdatefailed: + 'Something went wrong while updating the issue status.', issuetype: 'Issue Type', mediatype: 'Media Type', lastupdated: 'Last Updated', @@ -62,8 +63,8 @@ const messages = defineMessages({ episode: 'Episode {episodeNumber}', deleteissue: 'Delete Issue', deleteissueconfirm: 'Are you sure you want to delete this issue?', - toastissuedeleted: 'Issue deleted succesfully.', - toastissuedeletefailed: 'Something went wrong deleting the issue.', + toastissuedeleted: 'Deleted the issue successfully!', + toastissuedeletefailed: 'Something went wrong while deleting the issue.', nocomments: 'No comments.', unknownissuetype: 'Unknown', }); @@ -172,12 +173,7 @@ const IssueDetails: React.FC = () => { height: 493, }} > - + { > - {intl.formatMessage( - issueData.media.mediaType === MediaType.MOVIE - ? messages.openinradarr - : messages.openinsonarr - )} + {intl.formatMessage(messages.openinarr, { + arr: + issueData.media.mediaType === MediaType.MOVIE + ? 'Radarr' + : 'Sonarr', + })} )} @@ -581,11 +578,12 @@ const IssueDetails: React.FC = () => { > - {intl.formatMessage( - issueData.media.mediaType === MediaType.MOVIE - ? messages.openinradarr - : messages.openinsonarr - )} + {intl.formatMessage(messages.openinarr, { + arr: + issueData.media.mediaType === MediaType.MOVIE + ? 'Radarr' + : 'Sonarr', + })} )} diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 3773e4348..65704cc1c 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -27,8 +27,8 @@ const messages = defineMessages({ manageModalClearMedia: 'Clear Media Data', manageModalClearMediaWarning: '* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.', - openarr: 'Open {mediaType} in {arr}', - openarr4k: 'Open {mediaType} in 4K {arr}', + openarr: 'Open in {arr}', + openarr4k: 'Open in 4K {arr}', downloadstatus: 'Download Status', markavailable: 'Mark as Available', mark4kavailable: 'Mark as Available in 4K', diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index 37ecf9859..5fd774c4e 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -37,17 +37,19 @@ const messages = defineMessages({ 'Send notifications when media requests are declined.', usermediadeclinedDescription: 'Get notified when your media requests are declined.', - issuecreated: 'Issue Created', - issuecreatedDescription: 'Send notifications when new issues are created.', + issuecreated: 'Issue Reported', + issuecreatedDescription: 'Send notifications when issues are reported.', + userissuecreatedDescription: 'Get notified when other users report issues.', issuecomment: 'Issue Comment', issuecommentDescription: 'Send notifications when issues receive new comments.', userissuecommentDescription: - 'Send notifications when your issue receives new comments.', + 'Get notified when your issues receive new comments.', + adminissuecommentDescription: + 'Get notified when issues receive new comments.', issueresolved: 'Issue Resolved', issueresolvedDescription: 'Send notifications when issues are resolved.', - userissueresolvedDescription: - 'Send notifications when your issues are resolved.', + userissueresolvedDescription: 'Get notified when your issues are resolved.', }); export const hasNotificationType = ( @@ -99,7 +101,7 @@ export interface NotificationItem { name: string; description: string; value: Notification; - hasNotifyUser?: boolean; + hasNotifyUser: boolean; children?: NotificationItem[]; hidden?: boolean; } @@ -187,6 +189,7 @@ const NotificationTypeSelector: React.FC = ({ : messages.mediarequestedDescription ), value: Notification.MEDIA_PENDING, + hasNotifyUser: false, hidden: user && !hasPermission(Permission.MANAGE_REQUESTS), }, { @@ -198,6 +201,7 @@ const NotificationTypeSelector: React.FC = ({ : messages.mediaAutoApprovedDescription ), value: Notification.MEDIA_AUTO_APPROVED, + hasNotifyUser: false, hidden: user && !hasPermission(Permission.MANAGE_REQUESTS), }, { @@ -245,24 +249,33 @@ const NotificationTypeSelector: React.FC = ({ ), value: Notification.MEDIA_FAILED, hidden: user && !hasPermission(Permission.MANAGE_REQUESTS), + hasNotifyUser: false, }, { id: 'issue-created', name: intl.formatMessage(messages.issuecreated), - description: intl.formatMessage(messages.issuecreatedDescription), + description: intl.formatMessage( + user + ? messages.userissuecreatedDescription + : messages.issuecreatedDescription + ), value: Notification.ISSUE_CREATED, hidden: user && !hasPermission(Permission.MANAGE_ISSUES), + hasNotifyUser: false, }, { id: 'issue-comment', name: intl.formatMessage(messages.issuecomment), description: intl.formatMessage( user - ? messages.userissuecommentDescription + ? hasPermission(Permission.MANAGE_ISSUES) + ? messages.adminissuecommentDescription + : messages.userissuecommentDescription : messages.issuecommentDescription ), value: Notification.ISSUE_COMMENT, - hasNotifyUser: true, + hasNotifyUser: + !user || hasPermission(Permission.MANAGE_ISSUES) ? false : true, }, { id: 'issue-resolved', diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index b4b738250..504e615db 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -9,21 +9,24 @@ export const messages = defineMessages({ 'Full administrator access. Bypasses all other permission checks.', users: 'Manage Users', usersDescription: - 'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.', + 'Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.', settings: 'Manage Settings', settingsDescription: - 'Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.', + 'Grant permission to modify global settings. A user must have this permission to grant it to others.', managerequests: 'Manage Requests', managerequestsDescription: - 'Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.', + 'Grant permission to manage media requests. All requests made by a user with this permission will be automatically approved.', request: 'Request', - requestDescription: 'Grant permission to request non-4K media.', + requestDescription: 'Grant permission to submit requests for non-4K media.', requestMovies: 'Request Movies', - requestMoviesDescription: 'Grant permission to request non-4K movies.', + requestMoviesDescription: + 'Grant permission to submit requests for non-4K movies.', requestTv: 'Request Series', - requestTvDescription: 'Grant permission to request non-4K series.', + requestTvDescription: + 'Grant permission to submit requests for non-4K series.', autoapprove: 'Auto-Approve', - autoapproveDescription: 'Grant automatic approval for all non-4K requests.', + autoapproveDescription: + 'Grant automatic approval for all non-4K media requests.', autoapproveMovies: 'Auto-Approve Movies', autoapproveMoviesDescription: 'Grant automatic approval for non-4K movie requests.', @@ -31,7 +34,8 @@ export const messages = defineMessages({ autoapproveSeriesDescription: 'Grant automatic approval for non-4K series requests.', autoapprove4k: 'Auto-Approve 4K', - autoapprove4kDescription: 'Grant automatic approval for all 4K requests.', + autoapprove4kDescription: + 'Grant automatic approval for all 4K media requests.', autoapprove4kMovies: 'Auto-Approve 4K Movies', autoapprove4kMoviesDescription: 'Grant automatic approval for 4K movie requests.', @@ -39,22 +43,25 @@ export const messages = defineMessages({ autoapprove4kSeriesDescription: 'Grant automatic approval for 4K series requests.', request4k: 'Request 4K', - request4kDescription: 'Grant permission to request 4K media.', + request4kDescription: 'Grant permission to submit requests for 4K media.', request4kMovies: 'Request 4K Movies', - request4kMoviesDescription: 'Grant permission to request 4K movies.', + request4kMoviesDescription: + 'Grant permission to submit requests for 4K movies.', request4kTv: 'Request 4K Series', - request4kTvDescription: 'Grant permission to request 4K series.', + request4kTvDescription: 'Grant permission to submit requests for 4K series.', advancedrequest: 'Advanced Requests', advancedrequestDescription: - 'Grant permission to use advanced request options.', + 'Grant permission to modify advanced media request options.', viewrequests: 'View Requests', - viewrequestsDescription: "Grant permission to view other users' requests.", + viewrequestsDescription: + 'Grant permission to view media requests submitted by other users.', manageissues: 'Manage Issues', - manageissuesDescription: 'Grant permission to manage Overseerr issues.', - createissues: 'Create Issues', - createissuesDescription: 'Grant permission to create new issues.', + manageissuesDescription: 'Grant permission to manage media issues.', + createissues: 'Report Issues', + createissuesDescription: 'Grant permission to report media issues.', viewissues: 'View Issues', - viewissuesDescription: "Grant permission to view other users' issues.", + viewissuesDescription: + 'Grant permission to view media issues reported by other users.', }); interface PermissionEditProps { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 6abd1ba33..286f2b174 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -38,11 +38,9 @@ "components.IssueDetails.IssueComment.postedby": "Posted by {username} {relativeTime}", "components.IssueDetails.IssueComment.postedbyedited": "Posted by {username} {relativeTime} (Edited)", "components.IssueDetails.IssueComment.validationComment": "You must provide a message", - "components.IssueDetails.IssueDescription.cancel": "Cancel", "components.IssueDetails.IssueDescription.deleteissue": "Delete Issue", "components.IssueDetails.IssueDescription.description": "Description", "components.IssueDetails.IssueDescription.edit": "Edit Description", - "components.IssueDetails.IssueDescription.save": "Save Changes", "components.IssueDetails.allepisodes": "All Episodes", "components.IssueDetails.allseasons": "All Seasons", "components.IssueDetails.closeissue": "Close Issue", @@ -58,8 +56,7 @@ "components.IssueDetails.mediatype": "Media Type", "components.IssueDetails.nocomments": "No comments.", "components.IssueDetails.openedby": "#{issueId} opened {relativeTime} by {username}", - "components.IssueDetails.openinradarr": "Open in Radarr", - "components.IssueDetails.openinsonarr": "Open in Sonarr", + "components.IssueDetails.openinarr": "Open in {arr}", "components.IssueDetails.problemepisode": "Affected Episode", "components.IssueDetails.problemseason": "Affected Season", "components.IssueDetails.reopenissue": "Reopen Issue", @@ -67,12 +64,12 @@ "components.IssueDetails.season": "Season {seasonNumber}", "components.IssueDetails.statusopen": "Open", "components.IssueDetails.statusresolved": "Resolved", - "components.IssueDetails.toasteditdescriptionfailed": "Something went wrong editing the description.", - "components.IssueDetails.toasteditdescriptionsuccess": "Successfully edited the issue description.", - "components.IssueDetails.toastissuedeleted": "Issue deleted succesfully.", - "components.IssueDetails.toastissuedeletefailed": "Something went wrong deleting the issue.", - "components.IssueDetails.toaststatusupdated": "Issue status updated.", - "components.IssueDetails.toaststatusupdatefailed": "Something went wrong updating the issue status.", + "components.IssueDetails.toasteditdescriptionfailed": "Something went wrong while editing the issue description.", + "components.IssueDetails.toasteditdescriptionsuccess": "Edited the issue description successfully!", + "components.IssueDetails.toastissuedeleted": "Deleted the issue successfully!", + "components.IssueDetails.toastissuedeletefailed": "Something went wrong while deleting the issue.", + "components.IssueDetails.toaststatusupdated": "Updated the issue status successfully!", + "components.IssueDetails.toaststatusupdatefailed": "Something went wrong while updating the issue status.", "components.IssueDetails.unknownissuetype": "Unknown", "components.IssueList.IssueItem.allepisodes": "All Episodes", "components.IssueList.IssueItem.allseasons": "All Seasons", @@ -145,8 +142,8 @@ "components.ManageSlideOver.mark4kavailable": "Mark as Available in 4K", "components.ManageSlideOver.markavailable": "Mark as Available", "components.ManageSlideOver.movie": "movie", - "components.ManageSlideOver.openarr": "Open {mediaType} in {arr}", - "components.ManageSlideOver.openarr4k": "Open {mediaType} in 4K {arr}", + "components.ManageSlideOver.openarr": "Open in {arr}", + "components.ManageSlideOver.openarr4k": "Open in 4K {arr}", "components.ManageSlideOver.tvshow": "series", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MovieDetails.MovieCast.fullcast": "Full Cast", @@ -172,10 +169,11 @@ "components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}", "components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.watchtrailer": "Watch Trailer", + "components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when issues receive new comments.", "components.NotificationTypeSelector.issuecomment": "Issue Comment", "components.NotificationTypeSelector.issuecommentDescription": "Send notifications when issues receive new comments.", - "components.NotificationTypeSelector.issuecreated": "Issue Created", - "components.NotificationTypeSelector.issuecreatedDescription": "Send notifications when new issues are created.", + "components.NotificationTypeSelector.issuecreated": "Issue Reported", + "components.NotificationTypeSelector.issuecreatedDescription": "Send notifications when issues are reported.", "components.NotificationTypeSelector.issueresolved": "Issue Resolved", "components.NotificationTypeSelector.issueresolvedDescription": "Send notifications when issues are resolved.", "components.NotificationTypeSelector.mediaAutoApproved": "Media Automatically Approved", @@ -191,8 +189,9 @@ "components.NotificationTypeSelector.mediarequested": "Media Requested", "components.NotificationTypeSelector.mediarequestedDescription": "Send notifications when users submit new media requests which require approval.", "components.NotificationTypeSelector.notificationTypes": "Notification Types", - "components.NotificationTypeSelector.userissuecommentDescription": "Send notifications when your issue receives new comments.", - "components.NotificationTypeSelector.userissueresolvedDescription": "Send notifications when your issues are resolved.", + "components.NotificationTypeSelector.userissuecommentDescription": "Get notified when your issues receive new comments.", + "components.NotificationTypeSelector.userissuecreatedDescription": "Get notified when other users report issues.", + "components.NotificationTypeSelector.userissueresolvedDescription": "Get notified when your issues are resolved.", "components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.", "components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.", "components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.", @@ -202,45 +201,45 @@ "components.PermissionEdit.admin": "Admin", "components.PermissionEdit.adminDescription": "Full administrator access. Bypasses all other permission checks.", "components.PermissionEdit.advancedrequest": "Advanced Requests", - "components.PermissionEdit.advancedrequestDescription": "Grant permission to use advanced request options.", + "components.PermissionEdit.advancedrequestDescription": "Grant permission to modify advanced media request options.", "components.PermissionEdit.autoapprove": "Auto-Approve", "components.PermissionEdit.autoapprove4k": "Auto-Approve 4K", - "components.PermissionEdit.autoapprove4kDescription": "Grant automatic approval for all 4K requests.", + "components.PermissionEdit.autoapprove4kDescription": "Grant automatic approval for all 4K media requests.", "components.PermissionEdit.autoapprove4kMovies": "Auto-Approve 4K Movies", "components.PermissionEdit.autoapprove4kMoviesDescription": "Grant automatic approval for 4K movie requests.", "components.PermissionEdit.autoapprove4kSeries": "Auto-Approve 4K Series", "components.PermissionEdit.autoapprove4kSeriesDescription": "Grant automatic approval for 4K series requests.", - "components.PermissionEdit.autoapproveDescription": "Grant automatic approval for all non-4K requests.", + "components.PermissionEdit.autoapproveDescription": "Grant automatic approval for all non-4K media requests.", "components.PermissionEdit.autoapproveMovies": "Auto-Approve Movies", "components.PermissionEdit.autoapproveMoviesDescription": "Grant automatic approval for non-4K movie requests.", "components.PermissionEdit.autoapproveSeries": "Auto-Approve Series", "components.PermissionEdit.autoapproveSeriesDescription": "Grant automatic approval for non-4K series requests.", - "components.PermissionEdit.createissues": "Create Issues", - "components.PermissionEdit.createissuesDescription": "Grant permission to create new issues.", + "components.PermissionEdit.createissues": "Report Issues", + "components.PermissionEdit.createissuesDescription": "Grant permission to report media issues.", "components.PermissionEdit.manageissues": "Manage Issues", - "components.PermissionEdit.manageissuesDescription": "Grant permission to manage Overseerr issues.", + "components.PermissionEdit.manageissuesDescription": "Grant permission to manage media issues.", "components.PermissionEdit.managerequests": "Manage Requests", - "components.PermissionEdit.managerequestsDescription": "Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.", + "components.PermissionEdit.managerequestsDescription": "Grant permission to manage media requests. All requests made by a user with this permission will be automatically approved.", "components.PermissionEdit.request": "Request", "components.PermissionEdit.request4k": "Request 4K", - "components.PermissionEdit.request4kDescription": "Grant permission to request 4K media.", + "components.PermissionEdit.request4kDescription": "Grant permission to submit requests for 4K media.", "components.PermissionEdit.request4kMovies": "Request 4K Movies", - "components.PermissionEdit.request4kMoviesDescription": "Grant permission to request 4K movies.", + "components.PermissionEdit.request4kMoviesDescription": "Grant permission to submit requests for 4K movies.", "components.PermissionEdit.request4kTv": "Request 4K Series", - "components.PermissionEdit.request4kTvDescription": "Grant permission to request 4K series.", - "components.PermissionEdit.requestDescription": "Grant permission to request non-4K media.", + "components.PermissionEdit.request4kTvDescription": "Grant permission to submit requests for 4K series.", + "components.PermissionEdit.requestDescription": "Grant permission to submit requests for non-4K media.", "components.PermissionEdit.requestMovies": "Request Movies", - "components.PermissionEdit.requestMoviesDescription": "Grant permission to request non-4K movies.", + "components.PermissionEdit.requestMoviesDescription": "Grant permission to submit requests for non-4K movies.", "components.PermissionEdit.requestTv": "Request Series", - "components.PermissionEdit.requestTvDescription": "Grant permission to request non-4K series.", + "components.PermissionEdit.requestTvDescription": "Grant permission to submit requests for non-4K series.", "components.PermissionEdit.settings": "Manage Settings", - "components.PermissionEdit.settingsDescription": "Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.", + "components.PermissionEdit.settingsDescription": "Grant permission to modify global settings. A user must have this permission to grant it to others.", "components.PermissionEdit.users": "Manage Users", - "components.PermissionEdit.usersDescription": "Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.", + "components.PermissionEdit.usersDescription": "Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.", "components.PermissionEdit.viewissues": "View Issues", - "components.PermissionEdit.viewissuesDescription": "Grant permission to view other users' issues.", + "components.PermissionEdit.viewissuesDescription": "Grant permission to view media issues reported by other users.", "components.PermissionEdit.viewrequests": "View Requests", - "components.PermissionEdit.viewrequestsDescription": "Grant permission to view other users' requests.", + "components.PermissionEdit.viewrequestsDescription": "Grant permission to view media requests submitted by other users.", "components.PersonDetails.alsoknownas": "Also Known As: {names}", "components.PersonDetails.appearsin": "Appearances", "components.PersonDetails.ascharacter": "as {character}", diff --git a/src/styles/globals.css b/src/styles/globals.css index 228ec9c6f..c7a769a12 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -118,7 +118,7 @@ } .media-attributes { - @apply flex items-center mt-1 space-x-1 text-xs text-gray-300 sm:text-sm xl:text-base xl:mt-0; + @apply flex flex-wrap items-center justify-center mt-1 space-x-1 text-xs text-gray-300 sm:text-sm xl:text-base xl:mt-0 xl:justify-start; } .media-attributes a { From aeb7a48d72cec3fa2b857030aad3eaa0a457a896 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Mon, 25 Oct 2021 06:46:05 -0400 Subject: [PATCH 07/53] feat(notif): add Pushbullet and Pushover agents to user notification settings (#1740) * feat(notif): add Pushbullet and Pushover agents to user notification settings * docs(notif): add "hint" about user notifications to Pushbullet and Pushover pages * fix: regenerate DB migration --- .../notifications/pushbullet.md | 6 + .../using-overseerr/notifications/pushover.md | 6 + .../using-overseerr/notifications/telegram.md | 4 +- overseerr-api.yml | 9 + server/entity/UserSettings.ts | 9 + .../interfaces/api/userSettingsInterfaces.ts | 3 + server/lib/notifications/agents/pushbullet.ts | 178 ++++++++++---- server/lib/notifications/agents/pushover.ts | 189 +++++++++++---- server/lib/notifications/agents/telegram.ts | 78 +++--- server/lib/notifications/agents/webpush.ts | 10 +- ...63457-AddPushbulletPushoverUserSettings.ts | 33 +++ server/routes/user/usersettings.ts | 13 + .../Notifications/NotificationsTelegram.tsx | 4 +- .../UserNotificationsDiscord.tsx | 5 +- .../UserNotificationsEmail.tsx | 3 + .../UserNotificationsPushbullet.tsx | 172 +++++++++++++ .../UserNotificationsPushover.tsx | 228 ++++++++++++++++++ .../UserNotificationsTelegram.tsx | 5 +- .../UserNotificationsWebPush.tsx | 3 + .../UserNotificationSettings/index.tsx | 24 ++ src/i18n/locale/en.json | 13 + .../settings/notifications/pushbullet.tsx | 17 ++ .../settings/notifications/pushover.tsx | 17 ++ .../settings/notifications/pushbullet.tsx | 20 ++ .../settings/notifications/pushover.tsx | 20 ++ 25 files changed, 914 insertions(+), 155 deletions(-) create mode 100644 server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts create mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx create mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx create mode 100644 src/pages/profile/settings/notifications/pushbullet.tsx create mode 100644 src/pages/profile/settings/notifications/pushover.tsx create mode 100644 src/pages/users/[userId]/settings/notifications/pushbullet.tsx create mode 100644 src/pages/users/[userId]/settings/notifications/pushover.tsx diff --git a/docs/using-overseerr/notifications/pushbullet.md b/docs/using-overseerr/notifications/pushbullet.md index 45edcc3a0..6c6ba853e 100644 --- a/docs/using-overseerr/notifications/pushbullet.md +++ b/docs/using-overseerr/notifications/pushbullet.md @@ -1,5 +1,11 @@ # Pushbullet +{% hint style="info" %} +Users can optionally configure personal notifications in their user settings. + +User notifications are separate from system notifications, and the available notification types are dependent on user permissions. +{% endhint %} + ## Configuration ### Access Token diff --git a/docs/using-overseerr/notifications/pushover.md b/docs/using-overseerr/notifications/pushover.md index 55893dbad..cc09bfb69 100644 --- a/docs/using-overseerr/notifications/pushover.md +++ b/docs/using-overseerr/notifications/pushover.md @@ -1,5 +1,11 @@ # Pushover +{% hint style="info" %} +Users can optionally configure personal notifications in their user settings. + +User notifications are separate from system notifications, and the available notification types are dependent on user permissions. +{% endhint %} + ## Configuration ### Application/API Token diff --git a/docs/using-overseerr/notifications/telegram.md b/docs/using-overseerr/notifications/telegram.md index d0e6f6fcb..9bdb96dbc 100644 --- a/docs/using-overseerr/notifications/telegram.md +++ b/docs/using-overseerr/notifications/telegram.md @@ -1,7 +1,9 @@ # Telegram {% hint style="info" %} -Users can optionally configure their own notifications in their user settings. +Users can optionally configure personal notifications in their user settings. + +User notifications are separate from system notifications, and the available notification types are dependent on user permissions. {% endhint %} ## Configuration diff --git a/overseerr-api.yml b/overseerr-api.yml index 87d8061ea..00e1cf03a 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1630,6 +1630,15 @@ components: discordId: type: string nullable: true + pushbulletAccessToken: + type: string + nullable: true + pushoverApplicationToken: + type: string + nullable: true + pushoverUserKey: + type: string + nullable: true telegramEnabled: type: boolean telegramBotUsername: diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 02f391112..08397b12f 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -42,6 +42,15 @@ export class UserSettings { @Column({ nullable: true }) public discordId?: string; + @Column({ nullable: true }) + public pushbulletAccessToken?: string; + + @Column({ nullable: true }) + public pushoverApplicationToken?: string; + + @Column({ nullable: true }) + public pushoverUserKey?: string; + @Column({ nullable: true }) public telegramChatId?: string; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 18e3c7aba..0f743efef 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -22,6 +22,9 @@ export interface UserSettingsNotificationsResponse { discordEnabled?: boolean; discordEnabledTypes?: number; discordId?: string; + pushbulletAccessToken?: string; + pushoverApplicationToken?: string; + pushoverUserKey?: string; telegramEnabled?: boolean; telegramBotUsername?: string; telegramChatId?: string; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 160eed87f..3684803f7 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -1,11 +1,19 @@ import axios from 'axios'; +import { getRepository } from 'typeorm'; import { hasNotificationType, Notification } from '..'; import { MediaType } from '../../../constants/media'; +import { User } from '../../../entity/User'; import logger from '../../../logger'; -import { getSettings, NotificationAgentPushbullet } from '../../settings'; +import { Permission } from '../../permissions'; +import { + getSettings, + NotificationAgentKey, + NotificationAgentPushbullet, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; interface PushbulletPayload { + type: string; title: string; body: string; } @@ -25,22 +33,13 @@ class PushbulletAgent } public shouldSend(): boolean { - const settings = this.getSettings(); - - if (settings.enabled && settings.options.accessToken) { - return true; - } - - return false; + return true; } - private constructMessageDetails( + private getNotificationPayload( type: Notification, payload: NotificationPayload - ): { - title: string; - body: string; - } { + ): PushbulletPayload { let messageTitle = ''; let message = ''; @@ -126,6 +125,7 @@ class PushbulletAgent } return { + type: 'note', title: messageTitle, body: message, }; @@ -136,46 +136,132 @@ class PushbulletAgent payload: NotificationPayload ): Promise { const settings = this.getSettings(); + const endpoint = 'https://api.pushbullet.com/v2/pushes'; + const notificationPayload = this.getNotificationPayload(type, payload); - if (!hasNotificationType(type, settings.types ?? 0)) { - return true; - } - - logger.debug('Sending Pushbullet notification', { - label: 'Notifications', - type: Notification[type], - subject: payload.subject, - }); - - try { - const { title, body } = this.constructMessageDetails(type, payload); - - await axios.post( - 'https://api.pushbullet.com/v2/pushes', - { - type: 'note', - title: title, - body: body, - } as PushbulletPayload, - { - headers: { - 'Access-Token': settings.options.accessToken, - }, - } - ); - - return true; - } catch (e) { - logger.error('Error sending Pushbullet notification', { + // Send system notification + if ( + hasNotificationType(type, settings.types ?? 0) && + settings.enabled && + settings.options.accessToken + ) { + logger.debug('Sending Pushbullet notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, - errorMessage: e.message, - response: e.response?.data, }); - return false; + try { + await axios.post(endpoint, notificationPayload, { + headers: { + 'Access-Token': settings.options.accessToken, + }, + }); + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } } + + if (payload.notifyUser) { + // Send notification to the user who submitted the request + if ( + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.PUSHBULLET, + type + ) && + payload.notifyUser.settings?.pushbulletAccessToken && + payload.notifyUser.settings.pushbulletAccessToken !== + settings.options.accessToken + ) { + logger.debug('Sending Pushbullet notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, notificationPayload, { + headers: { + 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, + }, + }); + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + } else { + // Send notifications to all users with the Manage Requests permission + const userRepository = getRepository(User); + const users = await userRepository.find(); + + await Promise.all( + users + .filter( + (user) => + user.hasPermission(Permission.MANAGE_REQUESTS) && + user.settings?.hasNotificationType( + NotificationAgentKey.PUSHBULLET, + type + ) && + // Check if it's the user's own auto-approved request + (type !== Notification.MEDIA_AUTO_APPROVED || + user.id !== payload.request?.requestedBy.id) + ) + .map(async (user) => { + if ( + user.settings?.pushbulletAccessToken && + user.settings.pushbulletAccessToken !== + settings.options.accessToken + ) { + logger.debug('Sending Pushbullet notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, notificationPayload, { + headers: { + 'Access-Token': user.settings.pushbulletAccessToken, + }, + }); + } catch (e) { + logger.error('Error sending Pushbullet notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + }) + ); + } + + return true; } } diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index b37b54461..e66158063 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -1,8 +1,15 @@ import axios from 'axios'; +import { getRepository } from 'typeorm'; import { hasNotificationType, Notification } from '..'; import { MediaType } from '../../../constants/media'; +import { User } from '../../../entity/User'; import logger from '../../../logger'; -import { getSettings, NotificationAgentPushover } from '../../settings'; +import { Permission } from '../../permissions'; +import { + getSettings, + NotificationAgentKey, + NotificationAgentPushover, +} from '../../settings'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; interface PushoverPayload { @@ -31,29 +38,13 @@ class PushoverAgent } public shouldSend(): boolean { - const settings = this.getSettings(); - - if ( - settings.enabled && - settings.options.accessToken && - settings.options.userToken - ) { - return true; - } - - return false; + return true; } - private constructMessageDetails( + private getNotificationPayload( type: Notification, payload: NotificationPayload - ): { - title: string; - message: string; - url: string | undefined; - url_title: string | undefined; - priority: number; - } { + ): Partial { const settings = getSettings(); let messageTitle = ''; let message = ''; @@ -155,6 +146,7 @@ class PushoverAgent url, url_title, priority, + html: 1, }; } @@ -163,45 +155,138 @@ class PushoverAgent payload: NotificationPayload ): Promise { const settings = this.getSettings(); + const endpoint = 'https://api.pushover.net/1/messages.json'; + const notificationPayload = this.getNotificationPayload(type, payload); - if (!hasNotificationType(type, settings.types ?? 0)) { - return true; - } - - logger.debug('Sending Pushover notification', { - label: 'Notifications', - type: Notification[type], - subject: payload.subject, - }); - try { - const endpoint = 'https://api.pushover.net/1/messages.json'; - - const { title, message, url, url_title, priority } = - this.constructMessageDetails(type, payload); - - await axios.post(endpoint, { - token: settings.options.accessToken, - user: settings.options.userToken, - title: title, - message: message, - url: url, - url_title: url_title, - priority: priority, - html: 1, - } as PushoverPayload); - - return true; - } catch (e) { - logger.error('Error sending Pushover notification', { + // Send system notification + if ( + hasNotificationType(type, settings.types ?? 0) && + settings.enabled && + settings.options.accessToken && + settings.options.userToken + ) { + logger.debug('Sending Pushover notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, - errorMessage: e.message, - response: e.response?.data, }); - return false; + try { + await axios.post(endpoint, { + ...notificationPayload, + token: settings.options.accessToken, + user: settings.options.userToken, + } as PushoverPayload); + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } } + + if (payload.notifyUser) { + // Send notification to the user who submitted the request + if ( + payload.notifyUser.settings?.hasNotificationType( + NotificationAgentKey.PUSHOVER, + type + ) && + payload.notifyUser.settings?.pushoverApplicationToken && + payload.notifyUser.settings?.pushoverUserKey && + payload.notifyUser.settings.pushoverApplicationToken !== + settings.options.accessToken && + payload.notifyUser.settings?.pushoverUserKey !== + settings.options.userToken + ) { + logger.debug('Sending Pushover notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, { + ...notificationPayload, + token: payload.notifyUser.settings.pushoverApplicationToken, + user: payload.notifyUser.settings.pushoverUserKey, + } as PushoverPayload); + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + recipient: payload.notifyUser.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + } else { + // Send notifications to all users with the Manage Requests permission + const userRepository = getRepository(User); + const users = await userRepository.find(); + + await Promise.all( + users + .filter( + (user) => + user.hasPermission(Permission.MANAGE_REQUESTS) && + user.settings?.hasNotificationType( + NotificationAgentKey.PUSHOVER, + type + ) && + // Check if it's the user's own auto-approved request + (type !== Notification.MEDIA_AUTO_APPROVED || + user.id !== payload.request?.requestedBy.id) + ) + .map(async (user) => { + if ( + user.settings?.pushoverApplicationToken && + user.settings?.pushoverUserKey && + user.settings.pushoverApplicationToken !== + settings.options.accessToken && + user.settings.pushoverUserKey !== settings.options.userToken + ) { + logger.debug('Sending Pushover notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(endpoint, { + ...notificationPayload, + token: user.settings.pushoverApplicationToken, + user: user.settings.pushoverUserKey, + } as PushoverPayload); + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + recipient: user.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } + }) + ); + } + + return true; } } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index b63fbd62f..e71d29116 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -46,11 +46,7 @@ class TelegramAgent public shouldSend(): boolean { const settings = this.getSettings(); - if ( - settings.enabled && - settings.options.botAPI && - settings.options.chatId - ) { + if (settings.enabled && settings.options.botAPI) { return true; } @@ -61,12 +57,10 @@ class TelegramAgent return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : ''; } - private buildMessage( + private getNotificationPayload( type: Notification, - payload: NotificationPayload, - chatId: string, - sendSilently: boolean - ): TelegramMessagePayload | TelegramPhotoPayload { + payload: NotificationPayload + ): Partial { const settings = getSettings(); let message = ''; @@ -160,19 +154,15 @@ class TelegramAgent /* eslint-enable */ return payload.image - ? ({ + ? { photo: payload.image, caption: message, parse_mode: 'MarkdownV2', - chat_id: chatId, - disable_notification: !!sendSilently, - } as TelegramPhotoPayload) - : ({ + } + : { text: message, parse_mode: 'MarkdownV2', - chat_id: chatId, - disable_notification: !!sendSilently, - } as TelegramMessagePayload); + }; } public async send( @@ -180,13 +170,16 @@ class TelegramAgent payload: NotificationPayload ): Promise { const settings = this.getSettings(); - const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${ payload.image ? 'sendPhoto' : 'sendMessage' }`; + const notificationPayload = this.getNotificationPayload(type, payload); // Send system notification - if (hasNotificationType(type, settings.types ?? 0)) { + if ( + hasNotificationType(type, settings.types ?? 0) && + settings.options.chatId + ) { logger.debug('Sending Telegram notification', { label: 'Notifications', type: Notification[type], @@ -194,15 +187,11 @@ class TelegramAgent }); try { - await axios.post( - endpoint, - this.buildMessage( - type, - payload, - settings.options.chatId, - settings.options.sendSilently - ) - ); + await axios.post(endpoint, { + ...notificationPayload, + chat_id: settings.options.chatId, + disable_notification: !!settings.options.sendSilently, + } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { logger.error('Error sending Telegram notification', { label: 'Notifications', @@ -224,7 +213,7 @@ class TelegramAgent type ) && payload.notifyUser.settings?.telegramChatId && - payload.notifyUser.settings?.telegramChatId !== settings.options.chatId + payload.notifyUser.settings.telegramChatId !== settings.options.chatId ) { logger.debug('Sending Telegram notification', { label: 'Notifications', @@ -234,15 +223,12 @@ class TelegramAgent }); try { - await axios.post( - endpoint, - this.buildMessage( - type, - payload, - payload.notifyUser.settings.telegramChatId, - !!payload.notifyUser.settings.telegramSendSilently - ) - ); + await axios.post(endpoint, { + ...notificationPayload, + chat_id: payload.notifyUser.settings.telegramChatId, + disable_notification: + !!payload.notifyUser.settings.telegramSendSilently, + } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { logger.error('Error sending Telegram notification', { label: 'Notifications', @@ -287,15 +273,11 @@ class TelegramAgent }); try { - await axios.post( - endpoint, - this.buildMessage( - type, - payload, - user.settings.telegramChatId, - !!user.settings?.telegramSendSilently - ) - ); + await axios.post(endpoint, { + ...notificationPayload, + chat_id: user.settings.telegramChatId, + disable_notification: !!user.settings?.telegramSendSilently, + } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { logger.error('Error sending Telegram notification', { label: 'Notifications', diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 624dab223..1ab03ba67 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -206,6 +206,11 @@ class WebPushAgent settings.vapidPrivate ); + const notificationPayload = Buffer.from( + JSON.stringify(this.getNotificationPayload(type, payload)), + 'utf-8' + ); + await Promise.all( pushSubs.map(async (sub) => { logger.debug('Sending web push notification', { @@ -224,10 +229,7 @@ class WebPushAgent p256dh: sub.p256dh, }, }, - Buffer.from( - JSON.stringify(this.getNotificationPayload(type, payload)), - 'utf-8' - ) + notificationPayload ); } catch (e) { logger.error( diff --git a/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts new file mode 100644 index 000000000..8934866fa --- /dev/null +++ b/server/migration/1635079863457-AddPushbulletPushoverUserSettings.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPushbulletPushoverUserSettings1635079863457 + implements MigrationInterface +{ + name = 'AddPushbulletPushoverUserSettings1635079863457'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + } +} diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 226dcae09..6558115a7 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -257,6 +257,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( ? settings?.discord.types : 0, discordId: user.settings?.discordId, + pushbulletAccessToken: user.settings?.pushbulletAccessToken, + pushoverApplicationToken: user.settings?.pushoverApplicationToken, + pushoverUserKey: user.settings?.pushoverUserKey, telegramEnabled: settings?.telegram.enabled, telegramBotUsername: settings?.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, @@ -298,6 +301,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( user: req.user, pgpKey: req.body.pgpKey, discordId: req.body.discordId, + pushbulletAccessToken: req.body.pushbulletAccessToken, + pushoverApplicationToken: req.body.pushoverApplicationToken, + pushoverUserKey: req.body.pushoverUserKey, telegramChatId: req.body.telegramChatId, telegramSendSilently: req.body.telegramSendSilently, notificationTypes: req.body.notificationTypes, @@ -305,6 +311,10 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( } else { user.settings.pgpKey = req.body.pgpKey; user.settings.discordId = req.body.discordId; + user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken; + user.settings.pushoverApplicationToken = + req.body.pushoverApplicationToken; + user.settings.pushoverUserKey = req.body.pushoverUserKey; user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.notificationTypes = Object.assign( @@ -319,6 +329,9 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ pgpKey: user.settings?.pgpKey, discordId: user.settings?.discordId, + pushbulletAccessToken: user.settings?.pushbulletAccessToken, + pushoverApplicationToken: user.settings?.pushoverApplicationToken, + pushoverUserKey: user.settings?.pushoverUserKey, telegramChatId: user.settings?.telegramChatId, telegramSendSilently: user?.settings?.telegramSendSilently, notificationTypes: user.settings.notificationTypes, diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index bcb03df89..d76fdde33 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -51,8 +51,8 @@ const NotificationsTelegram: React.FC = () => { otherwise: Yup.string().nullable(), }), chatId: Yup.string() - .when('enabled', { - is: true, + .when(['enabled', 'types'], { + is: (enabled: boolean, types: number) => enabled && !!types, then: Yup.string() .nullable() .required(intl.formatMessage(messages.validationChatIdRequired)), diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx index 155c013b7..85d39ccfa 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx @@ -35,7 +35,7 @@ const UserNotificationsDiscord: React.FC = () => { const UserNotificationsDiscordSchema = Yup.object().shape({ discordId: Yup.string() .when('types', { - is: (value: unknown) => !!value, + is: (types: number) => !!types, then: Yup.string() .nullable() .required(intl.formatMessage(messages.validationDiscordId)), @@ -63,6 +63,9 @@ const UserNotificationsDiscord: React.FC = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, discordId: values.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, telegramChatId: data?.telegramChatId, telegramSendSilently: data?.telegramSendSilently, notificationTypes: { diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx index 576bdf14e..a0132d5f1 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx @@ -63,6 +63,9 @@ const UserEmailSettings: React.FC = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: values.pgpKey, discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, telegramChatId: data?.telegramChatId, telegramSendSilently: data?.telegramSendSilently, notificationTypes: { diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx new file mode 100644 index 000000000..615b6132e --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx @@ -0,0 +1,172 @@ +import axios from 'axios'; +import { Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; +import { useUser } from '../../../../hooks/useUser'; +import globalMessages from '../../../../i18n/globalMessages'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import SensitiveInput from '../../../Common/SensitiveInput'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const messages = defineMessages({ + pushbulletsettingssaved: + 'Pushbullet notification settings saved successfully!', + pushbulletsettingsfailed: 'Pushbullet notification settings failed to save.', + pushbulletAccessToken: 'Access Token', + pushbulletAccessTokenTip: + 'Create a token from your Account Settings', + validationPushbulletAccessToken: 'You must provide an access token', +}); + +const UserPushbulletSettings: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user } = useUser({ id: Number(router.query.userId) }); + const { data, error, revalidate } = useSWR( + user ? `/api/v1/user/${user?.id}/settings/notifications` : null + ); + + const UserNotificationsPushbulletSchema = Yup.object().shape({ + pushbulletAccessToken: Yup.string().when('types', { + is: (types: number) => !!types, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationPushbulletAccessToken)), + otherwise: Yup.string().nullable(), + }), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { + pgpKey: data?.pgpKey, + discordId: data?.discordId, + pushbulletAccessToken: values.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, + telegramChatId: data?.telegramChatId, + telegramSendSilently: data?.telegramSendSilently, + notificationTypes: { + pushbullet: values.types, + }, + }); + addToast(intl.formatMessage(messages.pushbulletsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.pushbulletsettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + isValid, + values, + setFieldValue, + setFieldTouched, + }) => { + return ( +
+
+ +
+
+ +
+ {errors.pushbulletAccessToken && + touched.pushbulletAccessToken && ( +
{errors.pushbulletAccessToken}
+ )} +
+
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + }} + error={ + errors.types && touched.types + ? (errors.types as string) + : undefined + } + /> +
+
+ + + +
+
+ + ); + }} +
+ ); +}; + +export default UserPushbulletSettings; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx new file mode 100644 index 000000000..0f88a795d --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx @@ -0,0 +1,228 @@ +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; +import { useUser } from '../../../../hooks/useUser'; +import globalMessages from '../../../../i18n/globalMessages'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const messages = defineMessages({ + pushoversettingssaved: 'Pushover notification settings saved successfully!', + pushoversettingsfailed: 'Pushover notification settings failed to save.', + pushoverApplicationToken: 'Application API Token', + pushoverApplicationTokenTip: + 'Register an application for use with Overseerr', + pushoverUserKey: 'User or Group Key', + pushoverUserKeyTip: + 'Your 30-character user or group identifier', + validationPushoverApplicationToken: + 'You must provide a valid application token', + validationPushoverUserKey: 'You must provide a valid user or group key', +}); + +const UserPushoverSettings: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user } = useUser({ id: Number(router.query.userId) }); + const { data, error, revalidate } = useSWR( + user ? `/api/v1/user/${user?.id}/settings/notifications` : null + ); + + const UserNotificationsPushoverSchema = Yup.object().shape({ + pushoverApplicationToken: Yup.string() + .when('types', { + is: (types: number) => !!types, + then: Yup.string() + .nullable() + .required( + intl.formatMessage(messages.validationPushoverApplicationToken) + ), + otherwise: Yup.string().nullable(), + }) + .matches( + /^[a-z\d]{30}$/i, + intl.formatMessage(messages.validationPushoverApplicationToken) + ), + pushoverUserKey: Yup.string() + .when('types', { + is: (types: number) => !!types, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationPushoverUserKey)), + otherwise: Yup.string().nullable(), + }) + .matches( + /^[a-z\d]{30}$/i, + intl.formatMessage(messages.validationPushoverUserKey) + ), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { + pgpKey: data?.pgpKey, + discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: values.pushoverApplicationToken, + pushoverUserKey: values.pushoverUserKey, + telegramChatId: data?.telegramChatId, + telegramSendSilently: data?.telegramSendSilently, + notificationTypes: { + pushover: values.types, + }, + }); + addToast(intl.formatMessage(messages.pushoversettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.pushoversettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + isValid, + values, + setFieldValue, + setFieldTouched, + }) => { + return ( +
+
+ +
+
+ +
+ {errors.pushoverApplicationToken && + touched.pushoverApplicationToken && ( +
+ {errors.pushoverApplicationToken} +
+ )} +
+
+
+ +
+
+ +
+ {errors.pushoverUserKey && touched.pushoverUserKey && ( +
{errors.pushoverUserKey}
+ )} +
+
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + }} + error={ + errors.types && touched.types + ? (errors.types as string) + : undefined + } + /> +
+
+ + + +
+
+ + ); + }} +
+ ); +}; + +export default UserPushoverSettings; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx index b27e5afed..96adfdcf8 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx @@ -37,7 +37,7 @@ const UserTelegramSettings: React.FC = () => { const UserNotificationsTelegramSchema = Yup.object().shape({ telegramChatId: Yup.string() .when('types', { - is: (value: unknown) => !!value, + is: (types: number) => !!types, then: Yup.string() .nullable() .required(intl.formatMessage(messages.validationTelegramChatId)), @@ -67,6 +67,9 @@ const UserTelegramSettings: React.FC = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, telegramChatId: values.telegramChatId, telegramSendSilently: values.telegramSendSilently, notificationTypes: { diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx index d2e36810a..6cfb46532 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx @@ -44,6 +44,9 @@ const UserWebPushSettings: React.FC = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, telegramChatId: data?.telegramChatId, telegramSendSilently: data?.telegramSendSilently, notificationTypes: { diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx index 0f58f7e7b..6f2cc64f1 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx @@ -5,6 +5,8 @@ import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; import DiscordLogo from '../../../../assets/extlogos/discord.svg'; +import PushbulletLogo from '../../../../assets/extlogos/pushbullet.svg'; +import PushoverLogo from '../../../../assets/extlogos/pushover.svg'; import TelegramLogo from '../../../../assets/extlogos/telegram.svg'; import { useUser } from '../../../../hooks/useUser'; import globalMessages from '../../../../i18n/globalMessages'; @@ -64,6 +66,28 @@ const UserNotificationSettings: React.FC = ({ children }) => { route: '/settings/notifications/discord', regex: /\/settings\/notifications\/discord/, }, + { + text: 'Pushbullet', + content: ( + + + Pushbullet + + ), + route: '/settings/notifications/pushbullet', + regex: /\/settings\/notifications\/pushbullet/, + }, + { + text: 'Pushover', + content: ( + + + Pushover + + ), + route: '/settings/notifications/pushover', + regex: /\/settings\/notifications\/pushover/, + }, { text: 'Telegram', content: ( diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 286f2b174..3647fe7a5 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -874,6 +874,16 @@ "components.UserProfile.UserSettings.UserNotificationSettings.notificationsettings": "Notification Settings", "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "PGP Public Key", "components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKeyTip": "Encrypt email messages using OpenPGP", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessToken": "Access Token", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessTokenTip": "Create a token from your Account Settings", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingsfailed": "Pushbullet notification settings failed to save.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingssaved": "Pushbullet notification settings saved successfully!", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverApplicationToken": "Application API Token", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverApplicationTokenTip": "Register an application for use with Overseerr", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKey": "User or Group Key", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKeyTip": "Your 30-character user or group identifier", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingsfailed": "Pushover notification settings failed to save.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingssaved": "Pushover notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.sendSilently": "Send Silently", "components.UserProfile.UserSettings.UserNotificationSettings.sendSilentlyDescription": "Send notifications with no sound", "components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "Chat ID", @@ -882,6 +892,9 @@ "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Telegram notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid user ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "You must provide a valid PGP public key", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "You must provide an access token", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "You must provide a valid application token", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "You must provide a valid user or group key", "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID", "components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push", "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingsfailed": "Web push notification settings failed to save.", diff --git a/src/pages/profile/settings/notifications/pushbullet.tsx b/src/pages/profile/settings/notifications/pushbullet.tsx new file mode 100644 index 000000000..12afd9413 --- /dev/null +++ b/src/pages/profile/settings/notifications/pushbullet.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; +import UserNotificationsPushbullet from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/src/pages/profile/settings/notifications/pushover.tsx b/src/pages/profile/settings/notifications/pushover.tsx new file mode 100644 index 000000000..83b8d71db --- /dev/null +++ b/src/pages/profile/settings/notifications/pushover.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; +import UserNotificationsPushover from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/src/pages/users/[userId]/settings/notifications/pushbullet.tsx b/src/pages/users/[userId]/settings/notifications/pushbullet.tsx new file mode 100644 index 000000000..cd7ca10c8 --- /dev/null +++ b/src/pages/users/[userId]/settings/notifications/pushbullet.tsx @@ -0,0 +1,20 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../../components/UserProfile/UserSettings'; +import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings'; +import UserNotificationsPushbullet from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet'; +import useRouteGuard from '../../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../../hooks/useUser'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/src/pages/users/[userId]/settings/notifications/pushover.tsx b/src/pages/users/[userId]/settings/notifications/pushover.tsx new file mode 100644 index 000000000..b37a866f7 --- /dev/null +++ b/src/pages/users/[userId]/settings/notifications/pushover.tsx @@ -0,0 +1,20 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../../components/UserProfile/UserSettings'; +import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings'; +import UserNotificationsPushover from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover'; +import useRouteGuard from '../../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../../hooks/useUser'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + + + ); +}; + +export default NotificationsPage; From 216447121b686b6d01a31b95ec0c8eb005f6b103 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Mon, 25 Oct 2021 11:07:00 -0400 Subject: [PATCH 08/53] fix(frontend): use consistent formatting & strings (#2231) * fix(frontend): use consistent formatting & strings * fix(lang): remove duplicated status strings * fix(frontend): reduce height of items in request & issue lists * fix(frontend): issue description textarea label should be a label element * refactor: remove unnecessary reduce * fix: remove small avatar underneath issue comments * fix(frontend): don't hide Pushover app token tip --- .../IssueDetails/IssueComment/index.tsx | 32 ++- src/components/IssueDetails/index.tsx | 242 +++++++++++------- src/components/IssueList/IssueItem/index.tsx | 60 +++-- src/components/IssueList/index.tsx | 4 +- .../IssueModal/CreateIssueModal/index.tsx | 6 +- src/components/ManageSlideOver/index.tsx | 11 +- src/components/RequestCard/index.tsx | 2 +- .../RequestList/RequestItem/index.tsx | 12 +- .../UserNotificationsPushover.tsx | 41 +-- src/i18n/locale/en.json | 30 +-- src/styles/globals.css | 2 +- 11 files changed, 264 insertions(+), 178 deletions(-) diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx index 603616da3..1b82ff6cf 100644 --- a/src/components/IssueDetails/IssueComment/index.tsx +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -3,6 +3,7 @@ import { ExclamationIcon } from '@heroicons/react/outline'; import { DotsVerticalIcon } from '@heroicons/react/solid'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; +import Link from 'next/link'; import React, { useState } from 'react'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import ReactMarkdown from 'react-markdown'; @@ -14,11 +15,11 @@ import Modal from '../../Common/Modal'; import Transition from '../../Transition'; const messages = defineMessages({ - postedby: 'Posted by {username} {relativeTime}', - postedbyedited: 'Posted by {username} {relativeTime} (Edited)', + postedby: 'Posted {relativeTime} by {username}', + postedbyedited: 'Posted {relativeTime} by {username} (Edited)', delete: 'Delete Comment', areyousuredelete: 'Are you sure you want to delete this comment?', - validationComment: 'You must provide a message', + validationComment: 'You must enter a message', edit: 'Edit Comment', }); @@ -86,11 +87,15 @@ const IssueComment: React.FC = ({ {intl.formatMessage(messages.areyousuredelete)}
- + + + + +
{(belongsToUser || hasPermission(Permission.MANAGE_ISSUES)) && ( @@ -221,7 +226,7 @@ const IssueComment: React.FC = ({
@@ -232,14 +237,15 @@ const IssueComment: React.FC = ({ : messages.postedby, { username: ( - - {comment.user.displayName} - + + {comment.user.displayName} + + ), relativeTime: ( {username}', + openedby: '#{issueId} opened {relativeTime} by {username}', closeissue: 'Close Issue', closeissueandcomment: 'Close with Comment', leavecomment: 'Comment', @@ -43,18 +43,18 @@ const messages = defineMessages({ reopenissue: 'Reopen Issue', reopenissueandcomment: 'Reopen with Comment', issuepagetitle: 'Issue', + playonplex: 'Play on Plex', + play4konplex: 'Play 4K on Plex', openinarr: 'Open in {arr}', - toasteditdescriptionsuccess: 'Edited the issue description successfully!', + openin4karr: 'Open in 4K {arr}', + toasteditdescriptionsuccess: 'Issue description edited successfully!', toasteditdescriptionfailed: 'Something went wrong while editing the issue description.', - toaststatusupdated: 'Updated the issue status successfully!', + toaststatusupdated: 'Issue status updated successfully!', toaststatusupdatefailed: 'Something went wrong while updating the issue status.', - issuetype: 'Issue Type', - mediatype: 'Media Type', + issuetype: 'Type', lastupdated: 'Last Updated', - statusopen: 'Open', - statusresolved: 'Resolved', problemseason: 'Affected Season', allseasons: 'All Seasons', season: 'Season {seasonNumber}', @@ -63,7 +63,7 @@ const messages = defineMessages({ episode: 'Episode {episodeNumber}', deleteissue: 'Delete Issue', deleteissueconfirm: 'Are you sure you want to delete this issue?', - toastissuedeleted: 'Deleted the issue successfully!', + toastissuedeleted: 'Issue deleted successfully!', toastissuedeletefailed: 'Something went wrong while deleting the issue.', nocomments: 'No comments.', unknownissuetype: 'Unknown', @@ -96,8 +96,6 @@ const IssueDetails: React.FC = () => { (opt) => opt.issueType === issueData?.issueType ); - const mediaType = issueData?.media.mediaType; - if (!data && !error) { return ; } @@ -212,7 +210,7 @@ const IssueDetails: React.FC = () => { />
)} -
+
{
{issueData.status === IssueStatus.OPEN && ( - {intl.formatMessage(messages.statusopen)} + {intl.formatMessage(globalMessages.open)} )} {issueData.status === IssueStatus.RESOLVED && ( - {intl.formatMessage(messages.statusresolved)} + {intl.formatMessage(globalMessages.resolved)} )}
@@ -259,27 +257,26 @@ const IssueDetails: React.FC = () => { {intl.formatMessage(messages.openedby, { issueId: issueData.id, - username: issueData.createdBy.displayName, - UserLink: function UserLink(msg) { - return ( -
- - - - - - - - {msg} - - -
- ); - }, + username: ( + + + + + {issueData.createdBy.displayName} + + + + ), relativeTime: ( { />
-
- {intl.formatMessage(messages.mediatype)} - - {intl.formatMessage( - mediaType === MediaType.MOVIE - ? globalMessages.movie - : globalMessages.tvshow - )} - -
{intl.formatMessage(messages.issuetype)} @@ -366,20 +353,66 @@ const IssueDetails: React.FC = () => {
- {hasPermission(Permission.MANAGE_ISSUES) && ( -
- {issueData?.media.serviceUrl && ( +
+ {issueData?.media.plexUrl && ( + + )} + {issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && ( + + )} + {issueData?.media.plexUrl4k && ( + + )} + {issueData?.media.serviceUrl4k && + hasPermission(Permission.ADMIN) && ( )} -
- )} +
@@ -513,16 +545,6 @@ const IssueDetails: React.FC = () => { )}
-
- {intl.formatMessage(messages.mediatype)} - - {intl.formatMessage( - mediaType === MediaType.MOVIE - ? globalMessages.movie - : globalMessages.tvshow - )} - -
{issueData.media.mediaType === MediaType.TV && ( <>
@@ -565,30 +587,74 @@ const IssueDetails: React.FC = () => {
- {hasPermission(Permission.MANAGE_ISSUES) && ( -
- {issueData?.media.serviceUrl && ( - - )} -
- )} +
+ {issueData?.media.plexUrl && ( + + )} + {issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && ( + + )} + {issueData?.media.plexUrl4k && ( + + )} + {issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && ( + + )} +
diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx index 25cb758ac..8a93c2e64 100644 --- a/src/components/IssueList/IssueItem/index.tsx +++ b/src/components/IssueList/IssueItem/index.tsx @@ -18,11 +18,9 @@ import { issueOptions } from '../../IssueModal/constants'; const messages = defineMessages({ openeduserdate: '{date} by {user}', - allseasons: 'All Seasons', - season: 'Season {seasonNumber}', + seasons: '{seasonCount, plural, one {Season} other {Seasons}}', + episodes: '{episodeCount, plural, one {Episode} other {Episodes}}', problemepisode: 'Affected Episode', - allepisodes: 'All Episodes', - episode: 'Episode {episodeNumber}', issuetype: 'Type', issuestatus: 'Status', opened: 'Opened', @@ -55,7 +53,7 @@ const IssueItem: React.FC = ({ issue }) => { if (!title && !error) { return (
); @@ -69,30 +67,48 @@ const IssueItem: React.FC = ({ issue }) => { (opt) => opt.issueType === issue?.issueType ); - const problemSeasonEpisodeLine = []; + const problemSeasonEpisodeLine: React.ReactNode[] = []; if (!isMovie(title) && issue) { problemSeasonEpisodeLine.push( - issue.problemSeason > 0 - ? intl.formatMessage(messages.season, { - seasonNumber: issue.problemSeason, - }) - : intl.formatMessage(messages.allseasons) + <> + + {intl.formatMessage(messages.seasons, { + seasonCount: issue.problemSeason ? 1 : 0, + })} + + + + {issue.problemSeason > 0 + ? issue.problemSeason + : intl.formatMessage(globalMessages.all)} + + + ); if (issue.problemSeason > 0) { problemSeasonEpisodeLine.push( - issue.problemEpisode > 0 - ? intl.formatMessage(messages.episode, { - episodeNumber: issue.problemEpisode, - }) - : intl.formatMessage(messages.allepisodes) + <> + + {intl.formatMessage(messages.episodes, { + episodeCount: issue.problemEpisode ? 1 : 0, + })} + + + + {issue.problemEpisode > 0 + ? issue.problemEpisode + : intl.formatMessage(globalMessages.all)} + + + ); } } return ( -
+
{title.backdropPath && (
= ({ issue }) => { : `/tv/${issue.media.tmdbId}` } > - + = ({ issue }) => { {problemSeasonEpisodeLine.length > 0 && ( -
- {problemSeasonEpisodeLine.join(' | ')} +
+ {problemSeasonEpisodeLine.map((t, k) => ( + {t} + ))}
)}
@@ -212,7 +230,7 @@ const IssueItem: React.FC = ({ issue }) => { alt="" className="ml-1.5 avatar-sm" /> - + {issue.createdBy.displayName} diff --git a/src/components/IssueList/index.tsx b/src/components/IssueList/index.tsx index 8a2559a13..cabf9ad95 100644 --- a/src/components/IssueList/index.tsx +++ b/src/components/IssueList/index.tsx @@ -94,7 +94,7 @@ const IssueList: React.FC = () => { <>
-
Issues
+
{intl.formatMessage(messages.issues)}
@@ -157,7 +157,7 @@ const IssueList: React.FC = () => {
{data.results.map((issue) => { return ( -
+
); diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 187fe0e54..2dd4ea8d8 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -278,10 +278,10 @@ const CreateIssueModal: React.FC = ({
- - {intl.formatMessage(messages.whatswrong)}{' '} + + - )} - + {isActiveUser && ( + + {({ active }) => ( + + )} + + )} {({ active }) => ( - )} - - - - {({ active }) => ( - - )} - + {belongsToUser && ( + + {({ active }) => ( + + )} + + )} + {(hasPermission(Permission.MANAGE_ISSUES) || + !commentCount) && ( + + {({ active }) => ( + + )} + + )}
diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index 57f6838f3..b0c065150 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -260,7 +260,7 @@ const IssueDetails: React.FC = () => { username: ( {
{ editFirstComment(newMessage); }} From 78a8091bcd29a7cf50cc7c493c28710389817adf Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 31 Oct 2021 11:32:57 -0400 Subject: [PATCH 15/53] fix(frontend): setup page backdrops (#2251) --- src/components/Setup/index.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/Setup/index.tsx b/src/components/Setup/index.tsx index 9d6642299..c4a754dc4 100644 --- a/src/components/Setup/index.tsx +++ b/src/components/Setup/index.tsx @@ -2,7 +2,7 @@ import axios from 'axios'; import { useRouter } from 'next/router'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { mutate } from 'swr'; +import useSWR, { mutate } from 'swr'; import useLocale from '../../hooks/useLocale'; import AppDataWarning from '../AppDataWarning'; import Badge from '../Common/Badge'; @@ -51,18 +51,21 @@ const Setup: React.FC = () => { } }; + const { data: backdrops } = useSWR('/api/v1/backdrops', { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + }); + return (
`https://www.themoviedb.org/t/p/original${backdrop}` + ) ?? [] + } />
From 8c49309c35c31f7bcd0b84b0a307febc16842f68 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 31 Oct 2021 16:39:08 +0100 Subject: [PATCH 16/53] feat(lang): translations update from Weblate (#2247) * feat(lang): translated using Weblate (German) Currently translated at 91.5% (904 of 987 strings) Co-authored-by: doob187 Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/de/ Translation: Overseerr/Overseerr Frontend * feat(lang): translated using Weblate (Swedish) Currently translated at 100.0% (987 of 987 strings) Co-authored-by: Hosted Weblate Co-authored-by: Shjosan Translate-URL: https://hosted.weblate.org/projects/overseerr/overseerr-frontend/sv/ Translation: Overseerr/Overseerr Frontend Co-authored-by: doob187 Co-authored-by: Shjosan --- src/i18n/locale/de.json | 60 +++++++++++++++++++++++++- src/i18n/locale/sv.json | 94 +++++++++++++++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/src/i18n/locale/de.json b/src/i18n/locale/de.json index 056a2b0aa..e17e73c9a 100644 --- a/src/i18n/locale/de.json +++ b/src/i18n/locale/de.json @@ -862,5 +862,63 @@ "components.MovieDetails.showmore": "Mehr Anzeigen", "components.MovieDetails.streamingproviders": "Streamt derzeit auf", "components.TvDetails.streamingproviders": "Streamt derzeit auf", - "components.StatusBadge.status": "{status}" + "components.StatusBadge.status": "{status}", + "components.IssueDetails.reopenissueandcomment": "Mit Kommentar wieder öffnen", + "components.IssueModal.CreateIssueModal.allseasons": "Alle Staffeln", + "components.IssueModal.CreateIssueModal.issomethingwrong": "Gibt es ein Problem mit {title}?", + "components.IssueModal.CreateIssueModal.problemepisode": "Betroffene Episode", + "components.IssueModal.CreateIssueModal.problemseason": "Betroffene Staffel", + "components.IssueModal.CreateIssueModal.providedetail": "Geben Sie eine detaillierte Erklärung des Problems an.", + "components.IssueModal.CreateIssueModal.reportissue": "Ein Problem melden", + "components.IssueDetails.IssueComment.areyousuredelete": "Möchten Sie diesen Kommentar wirklich löschen?", + "components.IssueDetails.IssueComment.delete": "Kommentar löschen", + "components.IssueDetails.IssueComment.edit": "Kommentar bearbeiten", + "components.IssueDetails.IssueComment.postedby": "Gepostet {relativeTime} von {username}", + "components.IssueDetails.IssueComment.postedbyedited": "Gepostet {relativeTime} von {username} (Bearbeitet)", + "components.IssueDetails.IssueComment.validationComment": "Sie müssen eine Nachricht eingeben", + "components.IssueDetails.IssueDescription.deleteissue": "Problem löschen", + "components.IssueDetails.toasteditdescriptionsuccess": "Problembeschreibung erfolgreich bearbeitet!", + "components.IssueDetails.toastissuedeleted": "Problem erfolgreich gelöscht!", + "components.IssueDetails.toasteditdescriptionfailed": "Beim Bearbeiten der Problembeschreibung ist ein Fehler aufgetreten.", + "components.IssueDetails.IssueDescription.description": "Beschreibung", + "components.IssueDetails.IssueDescription.edit": "Beschreibung bearbeiten", + "components.IssueDetails.allepisodes": "Alle Folgen", + "components.IssueDetails.allseasons": "Alle Staffeln", + "components.IssueDetails.closeissue": "Problem schließen", + "components.IssueDetails.closeissueandcomment": "Schließen mit Kommentar", + "components.IssueDetails.comments": "Kommentare", + "components.IssueDetails.deleteissue": "Problem löschen", + "components.IssueDetails.deleteissueconfirm": "Möchten Sie dieses Problem wirklich löschen?", + "components.IssueDetails.episode": "Folge {episodeNumber}", + "components.IssueDetails.issuepagetitle": "Problem", + "components.IssueDetails.issuetype": "Typ", + "components.IssueDetails.lastupdated": "Letzte Aktualisierung", + "components.IssueDetails.leavecomment": "Kommentar", + "components.IssueDetails.openinarr": "In {arr} öffnen", + "components.IssueDetails.toastissuedeletefailed": "Beim Löschen des Problems ist ein Fehler aufgetreten.", + "components.IssueDetails.toaststatusupdatefailed": "Beim Aktualisieren des Problemstatus ist ein Fehler aufgetreten.", + "components.IssueDetails.unknownissuetype": "Unbekannt", + "components.IssueList.IssueItem.issuetype": "Typ", + "components.IssueList.IssueItem.openeduserdate": "{date} von {user}", + "components.IssueList.IssueItem.problemepisode": "Betroffene Episode", + "components.IssueList.IssueItem.unknownissuetype": "Unbekannt", + "components.IssueList.showallissues": "Alle Probleme anzeigen", + "components.IssueList.sortAdded": "Anforderungsdatum", + "components.IssueList.sortModified": "Zuletzt geändert", + "components.IssueDetails.nocomments": "Keine Kommentare.", + "components.IssueDetails.openedby": "#{issueId} geöffnet {relativeTime} von {username}", + "components.IssueDetails.openin4karr": "In {arr} 4K öffnen", + "components.IssueDetails.play4konplex": "Auf Plex in 4K abspielen", + "components.IssueDetails.playonplex": "Auf Plex abspielen", + "components.IssueDetails.problemepisode": "Betroffene Episode", + "components.IssueDetails.problemseason": "Betroffene Staffeln", + "components.IssueDetails.reopenissue": "Problem erneut öffnen", + "components.IssueDetails.season": "Staffel {seasonNumber}", + "components.IssueDetails.toaststatusupdated": "Ausgabestatus erfolgreich aktualisiert!", + "components.IssueList.IssueItem.issuestatus": "Status", + "components.IssueList.IssueItem.opened": "Geöffnet", + "components.IssueList.IssueItem.viewissue": "Problem anzeigen", + "components.IssueModal.CreateIssueModal.allepisodes": "Alle Folgen", + "components.IssueModal.CreateIssueModal.season": "Staffel {seasonNumber}", + "components.IssueModal.CreateIssueModal.toastFailedCreate": "Beim Senden des Problems ist ein Fehler aufgetreten." } diff --git a/src/i18n/locale/sv.json b/src/i18n/locale/sv.json index 80331ee93..c6afd4a95 100644 --- a/src/i18n/locale/sv.json +++ b/src/i18n/locale/sv.json @@ -305,19 +305,19 @@ "components.Settings.csrfProtection": "Aktivera CSRF-skydd", "components.UserList.userssaved": "Användarbehörigheter sparade!", "components.UserList.bulkedit": "Mass-redigering", - "components.PermissionEdit.usersDescription": "Bevilja behörighet att hantera Overseerr-användare. Användare med denna behörighet kan inte ändra användare eller bevilja administratörsbehörighet.", + "components.PermissionEdit.usersDescription": "Bevilja behörighet att hantera användare. Användare med denna behörighet kan inte ändra användare med eller bevilja administratörsbehörighet.", "components.PermissionEdit.users": "Hantera Användare", - "components.PermissionEdit.settingsDescription": "Bevilja behörighet att modifiera Overseerr-inställningar. En användare måste ha denna behörighet för att kunna ge den till andra.", + "components.PermissionEdit.settingsDescription": "Ge tillstånd att ändra globala inställningar. En användare måste ha denna behörighet för att ge den till andra.", "components.PermissionEdit.settings": "Hantera Inställningar", - "components.PermissionEdit.requestDescription": "Bevilja behörighet att begära media som inte är 4K.", - "components.PermissionEdit.request4kTvDescription": "Bevilja behörighet att begära 4K serier.", + "components.PermissionEdit.requestDescription": "Ge tillstånd att skicka förfrågningar för icke-4K-media.", + "components.PermissionEdit.request4kTvDescription": "Bevilja tillstånd att skicka förfrågningar för 4K-serien.", "components.PermissionEdit.request4kTv": "Begära 4K Serier", - "components.PermissionEdit.request4kMoviesDescription": "Bevilja behörighet att begära 4K filmer.", + "components.PermissionEdit.request4kMoviesDescription": "Ge tillstånd att skicka in förfrågningar om 4K-filmer.", "components.PermissionEdit.request4kMovies": "Begära 4K Filmer", - "components.PermissionEdit.request4kDescription": "Bevilja behörighet att begära 4K media.", + "components.PermissionEdit.request4kDescription": "Bevilja behörighet att skicka förfrågningar om 4K-media.", "components.PermissionEdit.request4k": "Begära 4K", "components.PermissionEdit.request": "Begära", - "components.PermissionEdit.managerequestsDescription": "Bevilja behörighet att hantera Overseerr-förfrågningar. Alla förfrågningar som görs av en användare med den här behörigheten kommer att godkännas.", + "components.PermissionEdit.managerequestsDescription": "Bevilja behörighet att hantera medieförfrågningar. Alla förfrågningar som görs av en användare med den här behörigheten kommer att godkännas.", "components.PermissionEdit.managerequests": "Hantera Förfrågningar", "components.PermissionEdit.adminDescription": "Fullständig administratörsbehörighet. Överskrider alla andra behörighetskontroller.", "components.PlexLoginButton.signinwithplex": "Logga in", @@ -360,9 +360,9 @@ "components.PermissionEdit.autoapproveSeries": "Auto-Godkänn Serier", "components.PermissionEdit.autoapproveMoviesDescription": "Bevilja automatiskt godkännande för icke-4K-filmförfrågningar.", "components.PermissionEdit.autoapproveMovies": "Auto-Godkänn Filmer", - "components.PermissionEdit.autoapproveDescription": "Bevilja automatiskt godkännande för alla icke-4K-förfrågningar.", + "components.PermissionEdit.autoapproveDescription": "Bevilja automatiskt godkännande för alla icke-4K-medieförfrågningar.", "components.PermissionEdit.autoapprove": "Auto-Godkänn", - "components.PermissionEdit.advancedrequestDescription": "Ge behörighet att använda avancerade inställningar vid en begäran.", + "components.PermissionEdit.advancedrequestDescription": "Ge behörighet att redigera avancerade inställningar vid en begäran.", "components.PermissionEdit.advancedrequest": "Avancerade Förfrågningar", "components.PermissionEdit.admin": "Admin", "components.NotificationTypeSelector.mediadeclinedDescription": "Skicka meddelanden när medieförfrågningar avvisas.", @@ -409,7 +409,7 @@ "components.Settings.SonarrModal.validationApplicationUrlTrailingSlash": "URL:n får inte avslutas med ett slash", "components.Settings.SonarrModal.validationApplicationUrl": "Du måste ange en giltig URL", "components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL:n får inte avslutas med ett slash", - "components.PermissionEdit.viewrequestsDescription": "Bevilja behörighet att visa andra användares förfrågningar.", + "components.PermissionEdit.viewrequestsDescription": "Ge tillstånd att se medieförfrågningar som skickats av andra användare.", "components.PermissionEdit.viewrequests": "Visa Förfrågningar", "components.RequestModal.AdvancedRequester.requestas": "Begär Som", "components.Setup.setup": "Installationsguide", @@ -574,7 +574,7 @@ "components.PermissionEdit.autoapprove4kSeries": "Godkänn automatiskt 4K-serier", "components.PermissionEdit.autoapprove4kMoviesDescription": "Bevilja automatiskt godkännande för 4K-filmförfrågningar.", "components.PermissionEdit.autoapprove4kMovies": "Godkänn automatiskt 4K-filmer", - "components.PermissionEdit.autoapprove4kDescription": "Bevilja automatiskt godkännande för alla 4K-förfrågningar.", + "components.PermissionEdit.autoapprove4kDescription": "Bevilja automatiskt godkännande för alla 4K-medieförfrågningar.", "components.PermissionEdit.autoapprove4k": "Automatiskt godkännande av 4K", "components.NotificationTypeSelector.mediaAutoApprovedDescription": "Skicka meddelanden när användare skickar in nya medieförfrågningar som godkänns automatiskt.", "components.NotificationTypeSelector.mediaAutoApproved": "Media Automatiskt Godkänd", @@ -808,9 +808,9 @@ "components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "Pushbullet testmeddelande skickat!", "components.Settings.SettingsUsers.newPlexLoginTip": "Tillåt Plex-användare att logga in utan att först importeras", "components.Settings.SettingsUsers.newPlexLogin": "Aktivera ny Plex-inloggning", - "components.PermissionEdit.requestTvDescription": "Bevilja tillstånd att begära serier som inte är 4K.", + "components.PermissionEdit.requestTvDescription": "Ge tillstånd att skicka förfrågningar för icke-4K-serier.", "components.PermissionEdit.requestTv": "Begär serie", - "components.PermissionEdit.requestMoviesDescription": "Ger tillstånd att begära filmer som inte är 4K.", + "components.PermissionEdit.requestMoviesDescription": "Ge tillåtelse att skicka in förfrågningar om icke-4K-filmer.", "components.PermissionEdit.requestMovies": "Begär filmer", "components.UserProfile.UserSettings.UserGeneralSettings.languageDefault": "Standard ({language})", "components.Settings.locale": "Visningsspråk", @@ -919,5 +919,71 @@ "components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Avsnitt} other {Avsnitt}}", "components.IssueList.IssueItem.issuestatus": "Status", "components.IssueList.IssueItem.issuetype": "Typ", - "components.IssueList.IssueItem.problemepisode": "Påverkat avsnitt" + "components.IssueList.IssueItem.problemepisode": "Påverkat avsnitt", + "components.IssueModal.CreateIssueModal.toastSuccessCreate": "Problemrapport för {title} har skickats in!", + "components.ManageSlideOver.tvshow": "serie", + "components.ManageSlideOver.openarr4k": "Öppna i 4K {arr}", + "components.IssueModal.CreateIssueModal.reportissue": "Rapportera ett problem", + "components.IssueModal.CreateIssueModal.season": "Säsong {seasonNumber}", + "components.IssueModal.issueOther": "Övrigt", + "components.PermissionEdit.manageissuesDescription": "Bevilja behörighet att hantera medieproblem.", + "components.IssueModal.CreateIssueModal.toastFailedCreate": "Något gick fel när problemet skickades in.", + "components.IssueModal.CreateIssueModal.submitissue": "Skicka in problemet", + "components.ManageSlideOver.movie": "film", + "components.IssueModal.CreateIssueModal.whatswrong": "Vad är fel?", + "components.IssueModal.issueAudio": "Ljud", + "components.Layout.Sidebar.issues": "Problem", + "components.IssueModal.CreateIssueModal.validationMessageRequired": "Du måste ange en beskrivning", + "components.IssueModal.issueSubtitles": "Undertexter", + "components.IssueModal.issueVideo": "Video", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKey": "Användar- eller gruppnyckel", + "components.ManageSlideOver.openarr": "Öppna i {arr}", + "components.ManageSlideOver.manageModalTitle": "Hantera {mediaType}", + "components.ManageSlideOver.mark4kavailable": "Markera som tillgängligt i 4K", + "components.NotificationTypeSelector.adminissuecommentDescription": "Få aviseringar när problem får nya kommentarer.", + "components.ManageSlideOver.manageModalRequests": "Förfrågningar", + "components.PermissionEdit.manageissues": "Hantera problem", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingsfailed": "Inställningarna för pushover-meddelanden kunde inte sparas.", + "components.ManageSlideOver.markavailable": "Markera som tillgänglig", + "components.NotificationTypeSelector.issuecomment": "Problemkommentar", + "components.NotificationTypeSelector.issuecommentDescription": "Skicka aviseringar när problem får nya kommentarer.", + "components.PermissionEdit.createissuesDescription": "Bevilja behörighet att rapportera medieproblem.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverUserKeyTip": "Din 30-teckens användar- eller gruppidentifierare", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverApplicationTokenTip": "Registrera en applikation för användning med {applicationTitle}", + "components.PermissionEdit.viewissuesDescription": "Ge tillstånd att se medieproblem som rapporterats av andra användare.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoverApplicationToken": "Api-token för program", + "components.UserProfile.UserSettings.UserNotificationSettings.pushoversettingssaved": "Inställningar för pushover-meddelanden har sparats!", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "Du måste tillhandahålla en giltig applikationstoken", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "Du måste ange en giltig användar- eller gruppnyckel", + "components.IssueModal.CreateIssueModal.problemseason": "Påverkad säsong", + "components.IssueModal.CreateIssueModal.toastviewissue": "Visa problem", + "components.NotificationTypeSelector.issuecreated": "Problem rappoterat", + "components.PermissionEdit.createissues": "Rapportera problem", + "components.PermissionEdit.viewissues": "Visa problem", + "components.ManageSlideOver.manageModalClearMediaWarning": "* Detta tar bort all data för denna {mediaType}, inklusive eventuella begäranden, på ett oåterkalleligt sätt. Om det här objektet finns i ditt Plex-bibliotek kommer medieinformationen att återskapas vid nästa genomsökning.", + "components.ManageSlideOver.manageModalNoRequests": "Inga förfrågningar.", + "components.NotificationTypeSelector.userissueresolvedDescription": "Få meddelande när dina problem blir lösta.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessToken": "Åtkomsttoken", + "components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "Du måste tillhandahålla en åtkomsttoken", + "components.IssueList.sortAdded": "Datum för begäran", + "components.IssueList.sortModified": "Senast ändrad", + "components.IssueModal.CreateIssueModal.allepisodes": "Alla avsnitt", + "components.IssueModal.CreateIssueModal.allseasons": "Alla säsonger", + "components.IssueModal.CreateIssueModal.episode": "Avsnitt {episodeNumber}", + "components.IssueModal.CreateIssueModal.issomethingwrong": "Är det något problem med {title}?", + "components.IssueModal.CreateIssueModal.problemepisode": "Påverkat avsnitt", + "components.IssueModal.CreateIssueModal.providedetail": "Ge en detaljerad förklaring av problemet.", + "components.ManageSlideOver.allseasonsmarkedavailable": "* Alla säsonger kommer bli markerade som tillgängliga.", + "components.ManageSlideOver.downloadstatus": "Nerladdningsstatus", + "components.ManageSlideOver.manageModalClearMedia": "Rensa mediadata", + "components.NotificationTypeSelector.issuecreatedDescription": "Skicka aviseringar när problem rapporteras.", + "components.NotificationTypeSelector.issueresolved": "Problem löst", + "components.NotificationTypeSelector.issueresolvedDescription": "Skicka aviseringar när problem blir lösta.", + "components.NotificationTypeSelector.userissuecommentDescription": "Få meddelanden när dina problem får nya kommentarer.", + "components.NotificationTypeSelector.userissuecreatedDescription": "Få meddelanden när andra användare rapporterar problem.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletAccessTokenTip": "Skapa en token från dina kontoinställningar", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingsfailed": "Inställningar för Pushbullet-aviseringar kunde inte sparas.", + "components.UserProfile.UserSettings.UserNotificationSettings.pushbulletsettingssaved": "Inställningar för Pushbullet-aviseringar har sparats!", + "i18n.open": "Öppna", + "i18n.resolved": "Löst" } From 3ec4a9c76e1f31bee5c8801b389721bf8e5884e0 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sun, 31 Oct 2021 11:45:15 -0400 Subject: [PATCH 17/53] fix(frontend): more issues-related fixes (#2234) * fix(frontend): more issues-related fixes * fix: permission VIEW_ISSUES is also sufficient for viewing issues in slideover * fix(frontend): only display issue notif types user is eligible to receive * fix: don't display issues block in slideover if no open issues * fix: move year out of link in issue details header * fix: use 'view' global string for issue block button * fix: issue/request/user list sort options --- src/components/Common/Badge/index.tsx | 2 +- src/components/IssueBlock/index.tsx | 3 +- src/components/IssueDetails/index.tsx | 18 +++---- src/components/IssueList/IssueItem/index.tsx | 2 +- src/components/IssueList/index.tsx | 2 +- .../IssueModal/CreateIssueModal/index.tsx | 48 ++++++++++++++----- src/components/ManageSlideOver/index.tsx | 33 ++++++++----- src/components/MovieDetails/index.tsx | 39 ++++++++++----- .../NotificationTypeSelector/index.tsx | 10 ++++ src/components/RequestList/index.tsx | 2 +- src/components/TvDetails/index.tsx | 39 +++++++++------ src/components/UserList/index.tsx | 19 ++------ src/i18n/locale/en.json | 16 +++---- 13 files changed, 143 insertions(+), 90 deletions(-) diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 3868e8a40..bc450d7f4 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -13,7 +13,7 @@ const Badge: React.FC = ({ children, }) => { const badgeStyle = [ - 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full', + 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap', ]; if (url) { diff --git a/src/components/IssueBlock/index.tsx b/src/components/IssueBlock/index.tsx index 7e8067c47..2d3cfb33e 100644 --- a/src/components/IssueBlock/index.tsx +++ b/src/components/IssueBlock/index.tsx @@ -8,6 +8,7 @@ import Link from 'next/link'; import React from 'react'; import { useIntl } from 'react-intl'; import type Issue from '../../../server/entity/Issue'; +import globalMessages from '../../i18n/globalMessages'; import Button from '../Common/Button'; import { issueOptions } from '../IssueModal/constants'; @@ -56,7 +57,7 @@ const IssueBlock: React.FC = ({ issue }) => {
diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index b0c065150..ebdbabd86 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -44,7 +44,7 @@ const messages = defineMessages({ reopenissueandcomment: 'Reopen with Comment', issuepagetitle: 'Issue', playonplex: 'Play on Plex', - play4konplex: 'Play 4K on Plex', + play4konplex: 'Play in 4K on Plex', openinarr: 'Open in {arr}', openin4karr: 'Open in 4K {arr}', toasteditdescriptionsuccess: 'Issue description edited successfully!', @@ -228,7 +228,7 @@ const IssueDetails: React.FC = () => {
{issueData.status === IssueStatus.OPEN && ( - + {intl.formatMessage(globalMessages.open)} )} @@ -244,15 +244,11 @@ const IssueDetails: React.FC = () => { issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv' }/${data.id}`} > - - {title}{' '} - {releaseYear && ( - - ({releaseYear.slice(0, 4)}) - - )} - - + {title} + {' '} + {releaseYear && ( + ({releaseYear.slice(0, 4)}) + )} {intl.formatMessage(messages.openedby, { diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx index 8a93c2e64..267941fc2 100644 --- a/src/components/IssueList/IssueItem/index.tsx +++ b/src/components/IssueList/IssueItem/index.tsx @@ -183,7 +183,7 @@ const IssueItem: React.FC = ({ issue }) => { {intl.formatMessage(messages.issuestatus)} {issue.status === IssueStatus.OPEN ? ( - + {intl.formatMessage(globalMessages.open)} ) : ( diff --git a/src/components/IssueList/index.tsx b/src/components/IssueList/index.tsx index cabf9ad95..a78a762ca 100644 --- a/src/components/IssueList/index.tsx +++ b/src/components/IssueList/index.tsx @@ -19,7 +19,7 @@ import IssueItem from './IssueItem'; const messages = defineMessages({ issues: 'Issues', - sortAdded: 'Request Date', + sortAdded: 'Most Recent', sortModified: 'Last Modified', showallissues: 'Show All Issues', }); diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 2dd4ea8d8..c2d75e243 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -9,9 +9,12 @@ import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; import * as Yup from 'yup'; +import { MediaStatus } from '../../../../server/constants/media'; import type Issue from '../../../../server/entity/Issue'; import { MovieDetails } from '../../../../server/models/Movie'; import { TvDetails } from '../../../../server/models/Tv'; +import useSettings from '../../../hooks/useSettings'; +import { Permission, useUser } from '../../../hooks/useUser'; import globalMessages from '../../../i18n/globalMessages'; import Button from '../../Common/Button'; import Modal from '../../Common/Modal'; @@ -21,7 +24,9 @@ const messages = defineMessages({ validationMessageRequired: 'You must provide a description', issomethingwrong: 'Is there a problem with {title}?', whatswrong: "What's wrong?", - providedetail: 'Provide a detailed explanation of the issue.', + providedetail: + 'Please provide a detailed explanation of the issue you encountered.', + extras: 'Extras', season: 'Season {seasonNumber}', episode: 'Episode {episodeNumber}', allseasons: 'All Seasons', @@ -56,6 +61,8 @@ const CreateIssueModal: React.FC = ({ tmdbId, }) => { const intl = useIntl(); + const settings = useSettings(); + const { hasPermission } = useUser(); const { addToast } = useToasts(); const { data, error } = useSWR( tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null @@ -65,6 +72,20 @@ const CreateIssueModal: React.FC = ({ return null; } + const availableSeasons = (data?.mediaInfo?.seasons ?? []) + .filter( + (season) => + season.status === MediaStatus.AVAILABLE || + season.status === MediaStatus.PARTIALLY_AVAILABLE || + (settings.currentSettings.series4kEnabled && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { + type: 'or', + }) && + (season.status4k === MediaStatus.AVAILABLE || + season.status4k === MediaStatus.PARTIALLY_AVAILABLE)) + ) + .map((season) => season.seasonNumber); + const CreateIssueModalSchema = Yup.object().shape({ message: Yup.string().required( intl.formatMessage(messages.validationMessageRequired) @@ -76,7 +97,7 @@ const CreateIssueModal: React.FC = ({ initialValues={{ selectedIssue: issueOptions[0], message: '', - problemSeason: 0, + problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 0, problemEpisode: 0, }} validationSchema={CreateIssueModalSchema} @@ -162,18 +183,23 @@ const CreateIssueModal: React.FC = ({ as="select" id="problemSeason" name="problemSeason" + disabled={availableSeasons.length === 1} > - - {data.seasons.map((season) => ( + {availableSeasons.length > 1 && ( + + )} + {availableSeasons.map((season) => ( ))} diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index 5f865766b..9b8898ef3 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -19,6 +19,7 @@ import RequestBlock from '../RequestBlock'; const messages = defineMessages({ manageModalTitle: 'Manage {mediaType}', + manageModalIssues: 'Open Issues', manageModalRequests: 'Requests', manageModalNoRequests: 'No requests.', manageModalClearMedia: 'Clear Media Data', @@ -77,6 +78,11 @@ const ManageSlideOver: React.FC< revalidate(); }; + const openIssues = + data.mediaInfo?.issues?.filter( + (issue) => issue.status === IssueStatus.OPEN + ) ?? []; + return ( )} - {(data.mediaInfo?.issues ?? []).length > 0 && ( - <> -

Open Issues

-
-
    - {data.mediaInfo?.issues - ?.filter((issue) => issue.status === IssueStatus.OPEN) - .map((issue) => ( + {hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { + type: 'or', + }) && + openIssues.length > 0 && ( + <> +

    + {intl.formatMessage(messages.manageModalIssues)} +

    +
    +
      + {openIssues.map((issue) => (
    • ))} -
    -
    - - )} +
+
+ + )}

{intl.formatMessage(messages.manageModalRequests)}

diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index d7b0bbbbd..ee0ee8721 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -330,7 +330,14 @@ const MovieDetails: React.FC = ({ movie }) => { onUpdate={() => revalidate()} /> {(data.mediaInfo?.status === MediaStatus.AVAILABLE || - data.mediaInfo?.status4k === MediaStatus.AVAILABLE) && + (settings.currentSettings.movie4kEnabled && + hasPermission( + [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], + { + type: 'or', + } + ) && + data.mediaInfo?.status4k === MediaStatus.AVAILABLE)) && hasPermission( [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], { @@ -338,7 +345,7 @@ const MovieDetails: React.FC = ({ movie }) => { } ) && ( )}
diff --git a/src/components/NotificationTypeSelector/index.tsx b/src/components/NotificationTypeSelector/index.tsx index 5fd774c4e..bef9ed31b 100644 --- a/src/components/NotificationTypeSelector/index.tsx +++ b/src/components/NotificationTypeSelector/index.tsx @@ -274,6 +274,11 @@ const NotificationTypeSelector: React.FC = ({ : messages.issuecommentDescription ), value: Notification.ISSUE_COMMENT, + hidden: + user && + !hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), hasNotifyUser: !user || hasPermission(Permission.MANAGE_ISSUES) ? false : true, }, @@ -286,6 +291,11 @@ const NotificationTypeSelector: React.FC = ({ : messages.issueresolvedDescription ), value: Notification.ISSUE_RESOLVED, + hidden: + user && + !hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { + type: 'or', + }), hasNotifyUser: true, }, ]; diff --git a/src/components/RequestList/index.tsx b/src/components/RequestList/index.tsx index c74382282..7c1aa1545 100644 --- a/src/components/RequestList/index.tsx +++ b/src/components/RequestList/index.tsx @@ -22,7 +22,7 @@ import RequestItem from './RequestItem'; const messages = defineMessages({ requests: 'Requests', showallrequests: 'Show All Requests', - sortAdded: 'Request Date', + sortAdded: 'Most Recent', sortModified: 'Last Modified', }); diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 8ff39fb68..7365ba9a1 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -331,9 +331,14 @@ const TvDetails: React.FC = ({ tv }) => { is4kShowComplete={is4kComplete} /> {(data.mediaInfo?.status === MediaStatus.AVAILABLE || - data.mediaInfo?.status4k === MediaStatus.AVAILABLE || data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE || - data?.mediaInfo?.status4k === MediaStatus.PARTIALLY_AVAILABLE) && + (settings.currentSettings.series4kEnabled && + hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], { + type: 'or', + }) && + (data.mediaInfo?.status4k === MediaStatus.AVAILABLE || + data?.mediaInfo?.status4k === + MediaStatus.PARTIALLY_AVAILABLE))) && hasPermission( [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], { @@ -341,7 +346,7 @@ const TvDetails: React.FC = ({ tv }) => { } ) && ( )}
diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 8b7fac7a9..a544c8f99 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -46,8 +46,7 @@ const messages = defineMessages({ totalrequests: 'Requests', accounttype: 'Type', role: 'Role', - created: 'Created', - lastupdated: 'Updated', + created: 'Joined', bulkedit: 'Bulk Edit', owner: 'Owner', admin: 'Admin', @@ -75,8 +74,7 @@ const messages = defineMessages({ autogeneratepassword: 'Automatically Generate Password', autogeneratepasswordTip: 'Email a server-generated password to the user', validationEmail: 'You must provide a valid email address', - sortCreated: 'Creation Date', - sortUpdated: 'Last Updated', + sortCreated: 'Join Date', sortDisplayName: 'Display Name', sortRequests: 'Request Count', localLoginDisabled: @@ -91,7 +89,7 @@ const UserList: React.FC = () => { const settings = useSettings(); const { addToast } = useToasts(); const { user: currentUser, hasPermission: currentHasPermission } = useUser(); - const [currentSort, setCurrentSort] = useState('created'); + const [currentSort, setCurrentSort] = useState('displayname'); const [currentPageSize, setCurrentPageSize] = useState(10); const page = router.query.page ? Number(router.query.page) : 1; @@ -522,9 +520,6 @@ const UserList: React.FC = () => { - @@ -556,7 +551,6 @@ const UserList: React.FC = () => { {intl.formatMessage(messages.accounttype)} {intl.formatMessage(messages.role)} {intl.formatMessage(messages.created)} - {intl.formatMessage(messages.lastupdated)} {(data.results ?? []).length > 1 && (