From e402c42aaa7d795cd724856a2e23615bb1a3695d Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Sun, 24 Oct 2021 21:44:20 +0900 Subject: [PATCH] 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 | 234 +++--- 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 | 698 +++++++++--------- stylelint.config.js | 1 + 45 files changed, 4157 insertions(+), 834 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 bf324b02..87d8061e 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 0b1ca362..3f0d8654 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 00000000..85911853 --- /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 00000000..d8e05c56 --- /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 00000000..e4521639 --- /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 9666ac28..9cb8cd79 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 77f0e8b1..d54e31ae 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 00000000..bd17f195 --- /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 66c52a16..3de828f9 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 97be2cba..8e08e983 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 afe4b7c1..624dab22 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 a2eb0141..8769360f 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 fbf36e6b..95160d38 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 00000000..0c6116f9 --- /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 25386e05..3f57e815 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 00000000..d2774208 --- /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 00000000..9bc4e27b --- /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 8fed7410..2eac90f0 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; - - 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, - ]; - } +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[]; + + 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[]; - - 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 mediaStatusFilter: MediaStatus[]; - let sortFilter: string; + 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.sort) { - case 'modified': - sortFilter = 'request.updatedAt'; - break; - default: - sortFilter = 'request.id'; - } + let sortFilter: string; - 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, + 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, + } + ); + + 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 00000000..aab6bd94 --- /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 00000000..e76d2fd7 --- /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 fb9bf24c..f50e1d66 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 56b368d9..bfd654e9 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 736a4a6e..2d49e6a9 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 00000000..7e8067c4 --- /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 00000000..603616da --- /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 00000000..ba550afb --- /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 00000000..46ba759e --- /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 00000000..25cb758a --- /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 00000000..8a2559a1 --- /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 00000000..187fe0e5 --- /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 00000000..4c5b13e4 --- /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 00000000..f3f226de --- /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 689faf90..92495db6 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 0b71f670..37ecf985 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 71c6fc8b..b4b73825 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 a3a203e7..c625598b 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 6ccc6049..8ff39fb6 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 4c0ef790..34a2b710 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 789d05a7..6abd1ba3 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 00000000..730bee6e --- /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 00000000..0168d888 --- /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 fec0c712..228ec9c6 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); -} +@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; -} + body { + @apply bg-gray-900; + } -.searchbar { - padding-top: env(safe-area-inset-top); - height: calc(4rem + env(safe-area-inset-top)); -} + code { + @apply px-2 py-1 bg-gray-800 rounded-md; + } -.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%); + input[type='search']::-webkit-search-cancel-button { + -webkit-appearance: none; + } } -.slideover { - padding-top: calc(1.5rem + env(safe-area-inset-top)); - padding-bottom: 1.5rem; -} +@layer components { + .searchbar { + padding-top: env(safe-area-inset-top); + height: calc(4rem + env(safe-area-inset-top)); + } -.sidebar-close-button { - top: 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%); + } -.absolute-top-shift { - top: calc(-4rem - env(safe-area-inset-top)); -} + .slideover { + padding-top: calc(1.5rem + env(safe-area-inset-top)); + padding-bottom: 1.5rem; + } -.min-h-screen-shift { - min-height: calc(100vh + env(safe-area-inset-top)); -} + .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 { + @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; -} + .plex-button:hover { + background: #f19a30; + } -ul.cards-vertical, -ul.cards-horizontal { - @apply grid gap-4; -} + 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-vertical { + grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr)); + } -ul.cards-horizontal { - grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); -} + ul.cards-horizontal { + grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr)); + } -.slider-header { - @apply relative flex mt-6 mb-4; -} + .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; -} + .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 { + @apply transition duration-300 hover:text-white; + } -a.slider-title svg { - @apply w-6 h-6 ml-2; -} + 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 { + @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-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-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-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-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 { + @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; -} + .media-title > h1 { + @apply text-2xl font-bold xl:text-4xl; + } -h1 > .media-year { - @apply text-2xl; -} + 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 { + @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-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 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-actions > * { + @apply mb-3 sm:mb-0; + } -.media-overview { - @apply flex flex-col pt-8 pb-4 text-white lg:flex-row; -} + .media-overview { + @apply flex flex-col pt-8 pb-4 text-white lg:flex-row; + } -.media-overview-left { - @apply flex-1 lg:mr-8; -} + .media-overview-left { + @apply flex-1 lg:mr-8; + } -.tagline { - @apply mb-4 text-xl italic text-gray-400 lg:text-2xl; -} + .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 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; -} + .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 { + @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; -} + 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; -} + 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-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-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 { + @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-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-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; -} + .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; -} + .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; -} + .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; -} + .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; -} + 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 { + @apply flex items-center py-0.5 sm:py-1 text-sm truncate; + } -.card-field-name { - @apply mr-2 font-bold; -} + .card-field-name { + @apply mr-2 font-bold; + } -.card-field a { - @apply transition duration-300 hover:text-white hover:underline; -} + .card-field a { + @apply transition duration-300 hover:text-white hover:underline; + } -.section { - @apply mt-6 mb-10 text-white; -} + .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-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 { + @apply text-sm text-white sm:col-span-2; + } -.form-input-field { - @apply flex max-w-xl rounded-md shadow-sm; -} + .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; -} + .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, + .group-label { + @apply block mb-1 text-sm font-bold leading-5 text-gray-400; + } -label.checkbox-label { - @apply sm:mt-1; -} + label.checkbox-label { + @apply sm:mt-1; + } -label.text-label { - @apply sm:mt-2; -} + label.text-label { + @apply sm:mt-2; + } -label a { - @apply text-gray-100 transition duration-300 hover:text-white hover:underline; -} + label a { + @apply text-gray-100 transition duration-300 hover:text-white hover:underline; + } -.label-required { - @apply ml-1 text-red-500; -} + .label-required { + @apply ml-1 text-red-500; + } -.label-tip { - @apply block font-medium text-gray-500; -} + .label-tip { + @apply block font-medium text-gray-500; + } -button, -input, -select, -textarea { - @apply disabled:cursor-not-allowed; -} + 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='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[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-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.rounded-r-only, + select.rounded-r-only, + textarea.rounded-r-only { + @apply rounded-l-none; + } -input.short { - @apply w-20; -} + input.short { + @apply w-20; + } -select.short { - @apply w-min; -} + select.short { + @apply w-min; + } -button > span { - @apply whitespace-nowrap; -} + 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.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-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; -} + .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 { + @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; -} + .modal-icon svg { + @apply w-6 h-6; + } -svg.icon-md { - @apply w-5 h-5; -} + svg.icon-md { + @apply w-5 h-5; + } -svg.icon-sm { - @apply w-4 h-4; -} + 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; -} + .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; -} + .error { + @apply mt-2 text-sm text-red-500; + } -.form-group { - @apply mt-6 text-white; -} + .form-group { + @apply mt-6 text-white; + } -.toast { - width: 360px; -} + .toast { + width: 360px; + } -/* Used for animating height */ -.extra-max-height { - max-height: 100rem; -} + .react-select-container { + @apply w-full; + } -.hide-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} + .react-select-container .react-select__control { + @apply text-white bg-gray-700 border border-gray-500 rounded-md hover:border-gray-500; + } -.hide-scrollbar::-webkit-scrollbar { - display: none; -} + .react-select-container-dark .react-select__control { + @apply bg-gray-800 border border-gray-700; + } -/* Hide scrollbar for Chrome, Safari and Opera */ -.hide-scrollbar::-webkit-scrollbar { - display: none; -} + .react-select-container .react-select__control--is-focused { + @apply text-white bg-gray-700 border border-gray-500 rounded-md shadow; + } -/* Hide scrollbar for IE, Edge and Firefox */ -.hide-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} + .react-select-container-dark .react-select__control--is-focused { + @apply bg-gray-800 border-gray-600; + } -code { - @apply px-2 py-1 bg-gray-800 rounded-md; -} + .react-select-container .react-select__menu { + @apply text-gray-300 bg-gray-700; + } -input[type='search']::-webkit-search-cancel-button { - -webkit-appearance: none; -} + .react-select-container-dark .react-select__menu { + @apply bg-gray-800; + } -.react-select-container { - @apply w-full; -} + .react-select-container .react-select__option--is-focused { + @apply text-white bg-gray-600; + } -.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__option--is-focused { + @apply bg-gray-700; + } -.react-select-container-dark .react-select__control { - @apply bg-gray-800 border border-gray-700; -} + .react-select-container .react-select__indicator-separator { + @apply bg-gray-500; + } -.react-select-container .react-select__control--is-focused { - @apply text-white bg-gray-700 border border-gray-500 rounded-md shadow; -} + .react-select-container .react-select__indicator { + @apply text-gray-500; + } -.react-select-container-dark .react-select__control--is-focused { - @apply bg-gray-800 border-gray-600; -} + .react-select-container .react-select__placeholder { + @apply text-gray-400; + } -.react-select-container .react-select__menu { - @apply text-gray-300 bg-gray-700; -} + .react-select-container .react-select__multi-value { + @apply bg-gray-800 border border-gray-500 rounded-md; + } -.react-select-container-dark .react-select__menu { - @apply bg-gray-800; -} + .react-select-container .react-select__multi-value__label { + @apply text-white; + } -.react-select-container .react-select__option--is-focused { - @apply text-white bg-gray-600; -} + .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-dark .react-select__option--is-focused { - @apply bg-gray-700; -} + .react-select-container .react-select__input { + @apply text-base text-white border-none shadow-sm; + } -.react-select-container .react-select__indicator-separator { - @apply bg-gray-500; + .react-select-container .react-select__input input:focus { + @apply text-white border-none; + box-shadow: none; + } } -.react-select-container .react-select__indicator { - @apply text-gray-500; -} +@layer utilities { + .absolute-top-shift { + top: calc(-4rem - env(safe-area-inset-top)); + } -.react-select-container .react-select__placeholder { - @apply text-gray-400; -} + .min-h-screen-shift { + min-height: calc(100vh + env(safe-area-inset-top)); + } -.react-select-container .react-select__multi-value { - @apply bg-gray-800 border border-gray-500 rounded-md; -} + /* Used for animating height */ + .extra-max-height { + max-height: 100rem; + } -.react-select-container .react-select__multi-value__label { - @apply text-white; -} + .hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } -.react-select-container .react-select__multi-value__remove { - @apply cursor-pointer rounded-r-md hover:bg-red-700 hover:text-red-100; -} + .hide-scrollbar::-webkit-scrollbar { + display: none; + } -.react-select-container .react-select__input { - @apply text-base text-white border-none shadow-sm; -} + /* Hide scrollbar for Chrome, Safari and Opera */ + .hide-scrollbar::-webkit-scrollbar { + display: none; + } -.react-select-container .react-select__input input:focus { - @apply text-white border-none; - box-shadow: 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; + @media all and (display-mode: browser) { + .pwa-only { + @apply hidden; + } } } diff --git a/stylelint.config.js b/stylelint.config.js index 79a28459..7339dade 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -10,6 +10,7 @@ module.exports = { 'variants', 'responsive', 'screen', + 'layer', ], }, ],