feat: issues (#2180)

pull/1834/head
Ryan Cohen 3 years ago committed by GitHub
parent 6565c7dd9b
commit e402c42aaa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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: []

@ -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",

@ -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',
};

@ -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<Issue>) {
Object.assign(this, init);
}
}
export default Issue;

@ -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<IssueComment>) {
Object.assign(this, init);
}
}
export default IssueComment;

@ -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;

@ -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;

@ -0,0 +1,6 @@
import Issue from '../../entity/Issue';
import { PaginatedResponse } from './common';
export interface IssueResultsResponse extends PaginatedResponse {
results: Issue[];
}

@ -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<T extends NotificationAgentConfig> {

@ -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;
}

@ -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',
};
}
}

@ -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 = (

@ -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 {

@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIssues1634904083966 implements MigrationInterface {
name = 'AddIssues1634904083966';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

@ -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) => {

@ -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<Record<string, string>, 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<string, string>,
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;

@ -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;

@ -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<Record<string, unknown>, 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.' });
}
});

@ -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<IssueComment>
{
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<IssueComment>): void {
if (!event.entity) {
return;
}
this.sendIssueCommentNotification(event.entity);
}
}

@ -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<Issue> {
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<Issue>): void {
if (!event.entity) {
return;
}
this.sendIssueCreatedNotification(event.entity);
}
}

@ -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<Media> {
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;
}
}

@ -323,7 +323,9 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
{prev}
<span>|</span>
{curr}
</>
))}
</span>

@ -7,7 +7,7 @@ import Transition from '../../Transition';
interface SlideOverProps {
show?: boolean;
title: string;
title: React.ReactNode;
subText?: string;
onClose: () => void;
}

@ -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<IssueBlockProps> = ({ issue }) => {
const intl = useIntl();
const issueOption = issueOptions.find(
(opt) => opt.issueType === issue.issueType
);
if (!issueOption) {
return null;
}
return (
<div className="px-4 py-4 text-gray-300">
<div className="flex items-center justify-between">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5">
<div className="flex flex-nowrap">
<ExclamationIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(issueOption.name)}
</span>
</div>
<div className="flex mb-1 flex-nowrap white">
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto">
{issue.createdBy.displayName}
</span>
</div>
<div className="flex mb-1 flex-nowrap white">
<CalendarIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
<span className="w-40 truncate md:w-auto">
{intl.formatDate(issue.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>
<div className="flex flex-wrap flex-shrink-0 ml-2">
<Link href={`/issues/${issue.id}`} passHref>
<Button buttonType="primary" buttonSize="sm" as="a">
<EyeIcon />
<span>View</span>
</Button>
</Link>
</div>
</div>
</div>
);
};
export default IssueBlock;

@ -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<IssueCommentProps> = ({
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 (
<div
className={`flex ${
isReversed ? 'flex-row' : 'flex-row-reverse space-x-reverse'
} mt-4 space-x-4`}
>
<Transition
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
>
<Modal
title={intl.formatMessage(messages.delete)}
onCancel={() => setShowDeleteModal(false)}
onOk={() => deleteComment()}
okText={intl.formatMessage(messages.delete)}
okButtonType="danger"
iconSvg={<ExclamationIcon />}
>
{intl.formatMessage(messages.areyousuredelete)}
</Modal>
</Transition>
<img
src={comment.user.avatar}
alt=""
className="w-10 h-10 rounded-full ring-1 ring-gray-500"
/>
<div className="relative flex-1">
<div className="w-full rounded-md shadow ring-1 ring-gray-500">
{(belongsToUser || hasPermission(Permission.MANAGE_ISSUES)) && (
<Menu
as="div"
className="absolute z-40 inline-block text-left top-2 right-1"
>
{({ open }) => (
<>
<div>
<Menu.Button className="flex items-center text-gray-400 rounded-full hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500">
<span className="sr-only">Open options</span>
<DotsVerticalIcon
className="w-5 h-5"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
show={open}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className="absolute right-0 w-56 mt-2 origin-top-right bg-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => setIsEditing(true)}
className={`block w-full text-left px-4 py-2 text-sm ${
active
? 'bg-gray-600 text-white'
: 'text-gray-100'
}`}
>
{intl.formatMessage(messages.edit)}
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => setShowDeleteModal(true)}
className={`block w-full text-left px-4 py-2 text-sm ${
active
? 'bg-gray-600 text-white'
: 'text-gray-100'
}`}
>
{intl.formatMessage(messages.delete)}
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
)}
<div
className={`absolute w-3 h-3 transform rotate-45 bg-gray-800 shadow top-3 z-10 ring-1 ring-gray-500 ${
isReversed ? '-left-1' : '-right-1'
}`}
/>
<div className="relative z-20 w-full py-4 pl-4 pr-8 bg-gray-800 rounded-md">
{isEditing ? (
<Formik
initialValues={{ newMessage: comment.message }}
onSubmit={async (values) => {
await axios.put(`/api/v1/issueComment/${comment.id}`, {
message: values.newMessage,
});
if (onUpdate) {
onUpdate();
}
setIsEditing(false);
}}
validationSchema={EditCommentSchema}
>
{({ isValid, isSubmitting, errors, touched }) => {
return (
<Form>
<Field
as="textarea"
id="newMessage"
name="newMessage"
className="h-24"
/>
{errors.newMessage && touched.newMessage && (
<div className="error">{errors.newMessage}</div>
)}
<div className="flex items-center justify-end mt-4 space-x-2">
<Button
type="button"
onClick={() => setIsEditing(false)}
>
Cancel
</Button>
<Button
buttonType="primary"
disabled={!isValid || isSubmitting}
>
Save Changes
</Button>
</div>
</Form>
);
}}
</Formik>
) : (
<div className="w-full max-w-full prose">
<ReactMarkdown skipHtml allowedElements={['p', 'em', 'strong']}>
{comment.message}
</ReactMarkdown>
</div>
)}
</div>
</div>
<div
className={`flex justify-between items-center text-xs pt-2 px-2 ${
isReversed ? 'flex-row-reverse' : 'flex-row'
}`}
>
<span>
{intl.formatMessage(
comment.createdAt !== comment.updatedAt
? messages.postedbyedited
: messages.postedby,
{
username: (
<a
href={
isActiveUser ? '/profile' : `/users/${comment.user.id}`
}
className="font-semibold text-gray-100 transition duration-300 hover:underline hover:text-white"
>
{comment.user.displayName}
</a>
),
relativeTime: (
<FormattedRelativeTime
value={Math.floor(
(new Date(comment.createdAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
}
)}
</span>
</div>
</div>
</div>
);
};
export default IssueComment;

@ -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<IssueDescriptionProps> = ({
issueId,
description,
onEdit,
onDelete,
}) => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const [isEditing, setIsEditing] = useState(false);
return (
<div className="relative">
<div className="flex items-center justify-between">
<div className="font-semibold text-gray-100 lg:text-xl">
{intl.formatMessage(messages.description)}
</div>
{(hasPermission(Permission.MANAGE_ISSUES) || user?.id === issueId) && (
<Menu as="div" className="relative inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="flex items-center text-gray-400 rounded-full hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500">
<span className="sr-only">Open options</span>
<DotsVerticalIcon className="w-5 h-5" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className="absolute right-0 w-56 mt-2 origin-top-right bg-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => setIsEditing(true)}
className={`block w-full text-left px-4 py-2 text-sm ${
active
? 'bg-gray-600 text-white'
: 'text-gray-100'
}`}
>
{intl.formatMessage(messages.edit)}
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => onDelete()}
className={`block w-full text-left px-4 py-2 text-sm ${
active
? 'bg-gray-600 text-white'
: 'text-gray-100'
}`}
>
{intl.formatMessage(messages.deleteissue)}
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
)}
</div>
{isEditing ? (
<Formik
initialValues={{ newMessage: description }}
onSubmit={(values) => {
onEdit(values.newMessage);
setIsEditing(false);
}}
>
{() => {
return (
<Form className="mt-4">
<Field
id="newMessage"
name="newMessage"
as="textarea"
className="h-40"
/>
<div className="flex justify-end mt-2">
<Button
buttonType="default"
className="mr-2"
type="button"
onClick={() => setIsEditing(false)}
>
<span>{intl.formatMessage(messages.cancel)}</span>
</Button>
<Button buttonType="primary">
<span>{intl.formatMessage(messages.save)}</span>
</Button>
</div>
</Form>
);
}}
</Formik>
) : (
<div className="mt-4 prose">
<ReactMarkdown
allowedElements={['p', 'img', 'strong', 'em']}
skipHtml
>
{description}
</ReactMarkdown>
</div>
)}
</div>
);
};
export default IssueDescription;

@ -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 <UserLink>{username}</UserLink>',
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<Issue>(
`/api/v1/issue/${router.query.issueId}`
);
const { data, error } = useSWR<MovieDetails | TvDetails>(
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 <LoadingSpinner />;
}
if (!data || !issueData) {
return <Error statusCode={404} />;
}
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 (
<div
className="media-page"
style={{
height: 493,
}}
>
<PageTitle
title={[
intl.formatMessage(messages.issuepagetitle),
isMovie(data) ? data.title : data.name,
]}
/>
<Transition
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
>
<Modal
title={intl.formatMessage(messages.deleteissue)}
onCancel={() => setShowDeleteModal(false)}
onOk={() => deleteIssue()}
okText={intl.formatMessage(messages.deleteissue)}
okButtonType="danger"
iconSvg={<ExclamationIcon />}
>
{intl.formatMessage(messages.deleteissueconfirm)}
</Modal>
</Transition>
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<div className="flex flex-col items-center pt-4 lg:items-end lg:flex-row">
<div className="media-poster">
<CachedImage
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
{issueData.status === IssueStatus.OPEN && (
<Badge badgeType="primary">
{intl.formatMessage(messages.statusopen)}
</Badge>
)}
{issueData.status === IssueStatus.RESOLVED && (
<Badge badgeType="success">
{intl.formatMessage(messages.statusresolved)}
</Badge>
)}
</div>
<h1>
<Link
href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
}/${data.id}`}
>
<a className="hover:underline">
{title}{' '}
{releaseYear && (
<span className="media-year">
({releaseYear.slice(0, 4)})
</span>
)}
</a>
</Link>
</h1>
<span className="media-attributes">
{intl.formatMessage(messages.openedby, {
issueId: issueData.id,
username: issueData.createdBy.displayName,
UserLink: function UserLink(msg) {
return (
<div className="inline-flex items-center h-full mx-1">
<Link href={`/users/${issueData.createdBy.id}`}>
<a className="flex-shrink-0 w-6 h-6 mr-1">
<img
className="w-6 h-6 rounded-full"
src={issueData.createdBy.avatar}
alt=""
/>
</a>
</Link>
<Link href={`/users/${issueData.createdBy.id}`}>
<a className="font-semibold text-gray-100 transition hover:underline hover:text-white">
{msg}
</a>
</Link>
</div>
);
},
relativeTime: (
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.createdAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
})}
</span>
</div>
</div>
<div className="relative z-10 flex mt-6 text-gray-300">
<div className="flex-1 lg:pr-4">
<IssueDescription
issueId={issueData.id}
description={firstComment.message}
onEdit={(newMessage) => {
editFirstComment(newMessage);
}}
onDelete={() => setShowDeleteModal(true)}
/>
<div className="mt-8 lg:hidden">
<div className="media-facts">
<div className="media-fact">
<span>{intl.formatMessage(messages.mediatype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
mediaType === MediaType.MOVIE
? globalMessages.movie
: globalMessages.tvshow
)}
</span>
</div>
<div className="media-fact">
<span>{intl.formatMessage(messages.issuetype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
{issueData.media.mediaType === MediaType.TV && (
<>
<div className="media-fact">
<span>{intl.formatMessage(messages.problemseason)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemSeason > 0
? messages.season
: messages.allseasons,
{ seasonNumber: issueData.problemSeason }
)}
</span>
</div>
{issueData.problemSeason > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.problemepisode)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemEpisode > 0
? messages.episode
: messages.allepisodes,
{ episodeNumber: issueData.problemEpisode }
)}
</span>
</div>
)}
</>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.lastupdated)}</span>
<span className="media-fact-value">
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</div>
</div>
{hasPermission(Permission.MANAGE_ISSUES) && (
<div className="flex flex-col mt-4 mb-6 space-y-2">
{issueData?.media.serviceUrl && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ExternalLinkIcon />
<span>
{intl.formatMessage(
issueData.media.mediaType === MediaType.MOVIE
? messages.openinradarr
: messages.openinsonarr
)}
</span>
</Button>
)}
</div>
)}
</div>
<div className="mt-6">
<div className="font-semibold text-gray-100 lg:text-xl">
{intl.formatMessage(messages.comments)}
</div>
{otherComments.map((comment) => (
<IssueComment
comment={comment}
key={`issue-comment-${comment.id}`}
isReversed={issueData.createdBy.id === comment.user.id}
isActiveUser={comment.user.id === currentUser?.id}
onUpdate={() => revalidateIssue()}
/>
))}
{otherComments.length === 0 && (
<div className="mt-4 mb-10 text-gray-400">
<span>{intl.formatMessage(messages.nocomments)}</span>
</div>
)}
{(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && (
<Formik
initialValues={{
message: '',
}}
validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => {
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
message: values.message,
});
revalidateIssue();
resetForm();
}}
>
{({ isValid, isSubmitting, values, handleSubmit }) => {
return (
<Form>
<div className="my-6">
<Field
id="message"
name="message"
as="textarea"
placeholder="Respond with a comment..."
className="h-20"
/>
<div className="flex items-center justify-end mt-4 space-x-2">
{hasPermission(Permission.MANAGE_ISSUES) && (
<>
{issueData.status === IssueStatus.OPEN ? (
<Button
type="button"
buttonType="danger"
onClick={async () => {
await updateIssueStatus('resolved');
if (values.message) {
handleSubmit();
}
}}
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
values.message
? messages.closeissueandcomment
: messages.closeissue
)}
</span>
</Button>
) : (
<Button
type="button"
buttonType="default"
onClick={async () => {
await updateIssueStatus('open');
if (values.message) {
handleSubmit();
}
}}
>
<RefreshIcon />
<span>
{intl.formatMessage(
values.message
? messages.reopenissueandcomment
: messages.reopenissue
)}
</span>
</Button>
)}
</>
)}
<Button
type="submit"
buttonType="primary"
disabled={
!isValid || isSubmitting || !values.message
}
>
<ChatIcon />
<span>
{intl.formatMessage(messages.leavecomment)}
</span>
</Button>
</div>
</div>
</Form>
);
}}
</Formik>
)}
</div>
</div>
<div className="hidden lg:block lg:pl-4 lg:w-80">
<div className="media-facts">
<div className="media-fact">
<span>{intl.formatMessage(messages.issuetype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
<div className="media-fact">
<span>{intl.formatMessage(messages.mediatype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
mediaType === MediaType.MOVIE
? globalMessages.movie
: globalMessages.tvshow
)}
</span>
</div>
{issueData.media.mediaType === MediaType.TV && (
<>
<div className="media-fact">
<span>{intl.formatMessage(messages.problemseason)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemSeason > 0
? messages.season
: messages.allseasons,
{ seasonNumber: issueData.problemSeason }
)}
</span>
</div>
{issueData.problemSeason > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.problemepisode)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemEpisode > 0
? messages.episode
: messages.allepisodes,
{ episodeNumber: issueData.problemEpisode }
)}
</span>
</div>
)}
</>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.lastupdated)}</span>
<span className="media-fact-value">
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</div>
</div>
{hasPermission(Permission.MANAGE_ISSUES) && (
<div className="flex flex-col mt-4 mb-6 space-y-2">
{issueData?.media.serviceUrl && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ExternalLinkIcon />
<span>
{intl.formatMessage(
issueData.media.mediaType === MediaType.MOVIE
? messages.openinradarr
: messages.openinsonarr
)}
</span>
</Button>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default IssueDetails;

@ -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<IssueItemProps> = ({ 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<MovieDetails | TvDetails>(
inView ? url : null
);
if (!title && !error) {
return (
<div
className="w-full bg-gray-800 h-52 sm:h-40 xl:h-24 rounded-xl animate-pulse"
ref={ref}
/>
);
}
if (!title) {
return <div>uh oh</div>;
}
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 (
<div className="relative flex flex-col justify-between w-full py-2 overflow-hidden text-gray-400 bg-gray-800 shadow-md h-52 sm:h-40 xl:h-24 ring-1 ring-gray-700 rounded-xl xl:flex-row">
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
<CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0">
<Link
href={
issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
}
>
<a className="relative flex-shrink-0 w-10 h-auto overflow-hidden transition duration-300 scale-100 rounded-md transform-gpu hover:scale-105">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
objectFit="cover"
/>
</a>
</Link>
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4">
<div className="pt-0.5 sm:pt-1 text-xs text-white">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
4
)}
</div>
<Link
href={
issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
}
>
<a className="min-w-0 mr-2 text-lg font-bold text-white truncate xl:text-xl hover:underline">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{problemSeasonEpisodeLine.length > 0 && (
<div className="text-sm text-gray-200">
{problemSeasonEpisodeLine.join(' | ')}
</div>
)}
</div>
</div>
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.issuestatus)}
</span>
{issue.status === IssueStatus.OPEN ? (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.open)}
</Badge>
) : (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.resolved)}
</Badge>
)}
</div>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.issuetype)}
</span>
<span className="flex text-sm text-gray-300 truncate">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
<div className="card-field">
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
type: 'or',
}) ? (
<>
<span className="card-field-name">
{intl.formatMessage(messages.opened)}
</span>
<span className="flex text-sm text-gray-300 truncate">
{intl.formatMessage(messages.openeduserdate, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(issue.createdAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${issue.createdBy.id}`}>
<a className="flex items-center truncate group">
<img
src={issue.createdBy.avatar}
alt=""
className="ml-1.5 avatar-sm"
/>
<span className="text-sm truncate group-hover:underline">
{issue.createdBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.opened)}
</span>
<span className="flex text-sm text-gray-300 truncate">
<FormattedRelativeTime
value={Math.floor(
(new Date(issue.createdAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</>
)}
</div>
</div>
</div>
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">
<span className="w-full">
<Link href={`/issues/${issue.id}`} passHref>
<Button as="a" className="w-full" buttonType="primary">
<EyeIcon />
<span>{intl.formatMessage(messages.viewissue)}</span>
</Button>
</Link>
</span>
</div>
</div>
);
};
export default IssueItem;

@ -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>(Filter.OPEN);
const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(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<IssueResultsResponse>(
`/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 <LoadingSpinner />;
}
if (!data) {
return <LoadingSpinner />;
}
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
return (
<>
<PageTitle title={intl.formatMessage(messages.issues)} />
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
<Header>Issues</Header>
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
<FilterIcon className="w-6 h-6" />
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentFilter}
className="rounded-r-only"
>
<option value="all">
{intl.formatMessage(globalMessages.all)}
</option>
<option value="open">
{intl.formatMessage(globalMessages.open)}
</option>
<option value="resolved">
{intl.formatMessage(globalMessages.resolved)}
</option>
</select>
</div>
<div className="flex flex-grow mb-2 sm:mb-0 lg:flex-grow-0">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default sm:text-sm rounded-l-md">
<SortDescendingIcon className="w-6 h-6" />
</span>
<select
id="sort"
name="sort"
onChange={(e) => {
setCurrentSort(e.target.value as Sort);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentSort}
className="rounded-r-only"
>
<option value="added">
{intl.formatMessage(messages.sortAdded)}
</option>
<option value="modified">
{intl.formatMessage(messages.sortModified)}
</option>
</select>
</div>
</div>
</div>
{data.results.map((issue) => {
return (
<div className="mb-2" key={`issue-item-${issue.id}`}>
<IssueItem issue={issue} />
</div>
);
})}
{data.results.length === 0 && (
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
{currentFilter !== Filter.ALL && (
<div className="mt-4">
<Button
buttonType="primary"
onClick={() => setCurrentFilter(Filter.ALL)}
>
{intl.formatMessage(messages.showallissues)}
</Button>
</div>
)}
</div>
)}
<div className="actions">
<nav
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data.results.length > 0 &&
intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="inline short"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<Button
disabled={!hasPrevPage}
onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</>
);
};
export default IssueList;

@ -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 <strong>{title}</strong> 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<CreateIssueModalProps> = ({
onCancel,
mediaType,
tmdbId,
}) => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails | TvDetails>(
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null
);
if (!tmdbId) {
return null;
}
const CreateIssueModalSchema = Yup.object().shape({
message: Yup.string().required(
intl.formatMessage(messages.validationMessageRequired)
),
});
return (
<Formik
initialValues={{
selectedIssue: issueOptions[0],
message: '',
problemSeason: 0,
problemEpisode: 0,
}}
validationSchema={CreateIssueModalSchema}
onSubmit={async (values) => {
try {
const newIssue = await axios.post<Issue>('/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(
<>
<div>
{intl.formatMessage(messages.toastSuccessCreate, {
title: isMovie(data) ? data.title : data.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
})}
</div>
<Link href={`/issues/${newIssue.data.id}`}>
<Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowCircleRightIcon />
</Button>
</Link>
</>,
{
appearance: 'success',
autoDismiss: true,
}
);
}
if (onCancel) {
onCancel();
}
} catch (e) {
addToast(intl.formatMessage(messages.toastFailedCreate), {
appearance: 'error',
autoDismiss: true,
});
}
}}
>
{({ handleSubmit, values, setFieldValue, errors, touched }) => {
return (
<Modal
backgroundClickable
onCancel={onCancel}
iconSvg={<ExclamationIcon />}
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 && (
<div className="flex items-center">
<span className="mr-1 font-semibold">
{intl.formatMessage(messages.issomethingwrong, {
title: isMovie(data) ? data.title : data.name,
})}
</span>
</div>
)}
{mediaType === 'tv' && data && !isMovie(data) && (
<>
<div className="form-row">
<label htmlFor="problemSeason" className="text-label">
{intl.formatMessage(messages.problemseason)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field
as="select"
id="problemSeason"
name="problemSeason"
>
<option value={0}>
{intl.formatMessage(messages.allseasons)}
</option>
{data.seasons.map((season) => (
<option
value={season.seasonNumber}
key={`problem-season-${season.seasonNumber}`}
>
{intl.formatMessage(messages.season, {
seasonNumber: season.seasonNumber,
})}
</option>
))}
</Field>
</div>
</div>
</div>
{values.problemSeason > 0 && (
<div className="mb-2 form-row">
<label htmlFor="problemEpisode" className="text-label">
{intl.formatMessage(messages.problemepisode)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field
as="select"
id="problemEpisode"
name="problemEpisode"
>
<option value={0}>
{intl.formatMessage(messages.allepisodes)}
</option>
{[
...Array(
data.seasons.find(
(season) =>
Number(values.problemSeason) ===
season.seasonNumber
)?.episodeCount ?? 0
),
].map((i, index) => (
<option
value={index + 1}
key={`problem-episode-${index + 1}`}
>
{intl.formatMessage(messages.episode, {
episodeNumber: index + 1,
})}
</option>
))}
</Field>
</div>
</div>
</div>
)}
</>
)}
<RadioGroup
value={values.selectedIssue}
onChange={(issue) => setFieldValue('selectedIssue', issue)}
className="mt-4"
>
<RadioGroup.Label className="sr-only">
Select an Issue
</RadioGroup.Label>
<div className="-space-y-px overflow-hidden bg-gray-800 rounded-md bg-opacity-30">
{issueOptions.map((setting, index) => (
<RadioGroup.Option
key={`issue-type-${setting.issueType}`}
value={setting}
className={({ checked }) =>
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 }) => (
<>
<span
className={`${
checked
? 'bg-indigo-800 border-transparent'
: 'bg-white border-gray-300'
} ${
active ? 'ring-2 ring-offset-2 ring-indigo-300' : ''
} h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center`}
aria-hidden="true"
>
<span className="rounded-full bg-white w-1.5 h-1.5" />
</span>
<div className="flex flex-col ml-3">
<RadioGroup.Label
as="span"
className={`block text-sm font-medium ${
checked ? 'text-indigo-100' : 'text-gray-100'
}`}
>
{intl.formatMessage(setting.name)}
</RadioGroup.Label>
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
<div className="flex-col mt-4 space-y-2">
<span>
{intl.formatMessage(messages.whatswrong)}{' '}
<span className="label-required">*</span>
</span>
<Field
as="textarea"
name="message"
id="message"
className="h-28"
placeholder={intl.formatMessage(messages.providedetail)}
/>
{errors.message && touched.message && (
<div className="error">{errors.message}</div>
)}
</div>
</Modal>
);
}}
</Formik>
);
};
export default CreateIssueModal;

@ -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,
},
];

@ -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<IssueModalProps> = ({
show,
mediaType,
onCancel,
tmdbId,
}) => (
<Transition
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<CreateIssueModal
mediaType={mediaType}
onCancel={onCancel}
tmdbId={tmdbId}
/>
</Transition>
);
export default IssueModal;

@ -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: <ClockIcon className="w-6 h-6 mr-3" />,
activeRegExp: /^\/requests/,
},
{
href: '/issues',
messagesKey: 'issues',
svgIcon: (
<ExclamationIcon className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
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<SidebarProps> = ({ open, setClosed }) => {
<nav className="flex-1 px-4 mt-16 space-y-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
? hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
: true
).map((sidebarLink) => {
return (
@ -188,7 +207,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<nav className="flex-1 px-4 mt-16 space-y-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
? hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
: true
).map((sidebarLink) => {
return (

@ -0,0 +1,271 @@
import {
CheckCircleIcon,
DocumentRemoveIcon,
ExternalLinkIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import { MovieDetails } from '../../../server/models/Movie';
import { TvDetails } from '../../../server/models/Tv';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import ConfirmButton from '../Common/ConfirmButton';
import SlideOver from '../Common/SlideOver';
import DownloadBlock from '../DownloadBlock';
import IssueBlock from '../IssueBlock';
import RequestBlock from '../RequestBlock';
const messages = defineMessages({
manageModalTitle: 'Manage {mediaType}',
manageModalRequests: 'Requests',
manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear Media Data',
manageModalClearMediaWarning:
'* This will irreversibly remove all data for this {mediaType}, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
openarr: 'Open {mediaType} in {arr}',
openarr4k: 'Open {mediaType} in 4K {arr}',
downloadstatus: 'Download Status',
markavailable: 'Mark as Available',
mark4kavailable: 'Mark as Available in 4K',
allseasonsmarkedavailable: '* All seasons will be marked as available.',
// Recreated here for lowercase versions to go with the modal clear media warning
movie: 'movie',
tvshow: 'series',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
interface ManageSlideOverProps {
// mediaType: 'movie' | 'tv';
show?: boolean;
onClose: () => void;
revalidate: () => void;
}
interface ManageSlideOverMovieProps extends ManageSlideOverProps {
mediaType: 'movie';
data: MovieDetails;
}
interface ManageSlideOverTvProps extends ManageSlideOverProps {
mediaType: 'tv';
data: TvDetails;
}
const ManageSlideOver: React.FC<
ManageSlideOverMovieProps | ManageSlideOverTvProps
> = ({ show, mediaType, onClose, data, revalidate }) => {
const { hasPermission } = useUser();
const intl = useIntl();
const settings = useSettings();
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();
};
return (
<SlideOver
show={show}
title={intl.formatMessage(messages.manageModalTitle, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow
),
})}
onClose={() => onClose()}
subText={isMovie(data) ? data.title : data.name}
>
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} />
</li>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} is4k />
</li>
))}
</ul>
</div>
</>
)}
{data?.mediaInfo &&
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled)) && (
<div className="mb-6">
{data?.mediaInfo &&
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable()}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
</div>
)}
{data?.mediaInfo &&
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable(true)}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>{intl.formatMessage(messages.mark4kavailable)}</span>
</Button>
</div>
)}
{mediaType === 'tv' && (
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.allseasonsmarkedavailable)}
</div>
)}
</div>
)}
{(data.mediaInfo?.issues ?? []).length > 0 && (
<>
<h3 className="mb-2 text-xl">Open Issues</h3>
<div className="mb-4 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.issues
?.filter((issue) => issue.status === IssueStatus.OPEN)
.map((issue) => (
<li
key={`manage-issue-${issue.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<IssueBlock issue={issue} />
</li>
))}
</ul>
</div>
</>
)}
<h3 className="mb-2 text-xl">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
</ul>
</div>
{hasPermission(Permission.ADMIN) &&
(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
<div className="mt-8">
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>
{intl.formatMessage(messages.openarr, {
mediaType: intl.formatMessage(
mediaType === 'movie'
? globalMessages.movie
: globalMessages.tvshow
),
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</Button>
</a>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>
{intl.formatMessage(messages.openarr4k, {
mediaType: intl.formatMessage(
mediaType === 'movie'
? globalMessages.movie
: globalMessages.tvshow
),
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo && (
<div className="mt-8">
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
</ConfirmButton>
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? messages.movie : messages.tvshow
),
})}
</div>
</div>
)}
</SlideOver>
);
};
export default ManageSlideOver;

@ -2,18 +2,15 @@ import {
ArrowCircleRightIcon,
CloudIcon,
CogIcon,
ExclamationIcon,
FilmIcon,
PlayIcon,
TicketIcon,
} from '@heroicons/react/outline';
import {
CheckCircleIcon,
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
DocumentRemoveIcon,
ExternalLinkIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import { uniqBy } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
@ -21,6 +18,7 @@ import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
@ -36,16 +34,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 Slider from '../Slider';
import StatusBadge from '../StatusBadge';
@ -64,17 +60,8 @@ const messages = defineMessages({
recommendations: 'Recommendations',
similar: 'Similar Titles',
overviewunavailable: 'Overview unavailable.',
manageModalTitle: 'Manage Movie',
manageModalRequests: 'Requests',
manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear Media Data',
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.',
studio: '{studioCount, plural, one {Studio} other {Studios}}',
viewfullcrew: 'View Full Crew',
openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status',
playonplex: 'Play on Plex',
play4konplex: 'Play in 4K on Plex',
markavailable: 'Mark as Available',
@ -97,6 +84,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const [showManager, setShowManager] = useState(false);
const minStudios = 3;
const [showMoreStudios, setShowMoreStudios] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}`,
@ -164,20 +152,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
});
}
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
@ -264,141 +238,19 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
<PageTitle title={data.title} />
<SlideOver
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="movie"
tmdbId={data.id}
/>
<ManageSlideOver
data={data}
mediaType="movie"
onClose={() => setShowManager(false)}
subText={data.title}
>
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} />
</li>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} is4k />
</li>
))}
</ul>
</div>
</>
)}
{data?.mediaInfo &&
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.movie4kEnabled)) && (
<div className="mb-6">
{data?.mediaInfo &&
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable()}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
</div>
)}
{data?.mediaInfo &&
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.movie4kEnabled && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable(true)}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(messages.mark4kavailable)}
</span>
</Button>
</div>
)}
</div>
)}
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
</ul>
</div>
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
<div className="mt-8">
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.openradarr)}</span>
</Button>
</a>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.openradarr4k)}</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo && (
<div className="mt-8">
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
</ConfirmButton>
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
revalidate={() => revalidate()}
show={showManager}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
@ -462,7 +314,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
{prev}
<span>|</span>
{curr}
</>
))}
</span>
@ -475,13 +329,39 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ 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',
}
) && (
<Button
buttonType="danger"
className="ml-2 first:ml-0"
onClick={() => setShowIssueModal(true)}
>
<ExclamationIcon />
</Button>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<CogIcon />
<CogIcon className="!mr-0" />
{(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
</>
)}
</Button>
)}
</div>

@ -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<NotificationTypeSelectorProps> = ({
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(

@ -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<PermissionEditProps> = ({
},
],
},
{
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 (

@ -104,7 +104,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null
inView ? url : null
);
const {
data: requestData,

@ -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<TvDetailsProps> = ({ tv }) => {
const { locale } = useLocale();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false);
const { data, error, revalidate } = useSWR<TvDetailsType>(
`/api/v1/tv/${router.query.tvId}`,
@ -156,20 +139,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ 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<TvDetailsProps> = ({ tv }) => {
</div>
)}
<PageTitle title={data.name} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="tv"
tmdbId={data.id}
/>
<RequestModal
tmdbId={data.id}
show={showRequestModal}
@ -271,144 +246,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}}
onCancel={() => setShowRequestModal(false)}
/>
<SlideOver
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
<ManageSlideOver
data={data}
mediaType="tv"
onClose={() => setShowManager(false)}
subText={data.name}
>
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} />
</li>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} is4k />
</li>
))}
</ul>
</div>
</>
)}
{data?.mediaInfo &&
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled)) && (
<div className="mb-6">
{data?.mediaInfo &&
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable()}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
</div>
)}
{data?.mediaInfo &&
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable(true)}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(messages.mark4kavailable)}
</span>
</Button>
</div>
)}
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.allseasonsmarkedavailable)}
</div>
</div>
)}
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
</ul>
</div>
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
<div className="mt-8">
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.opensonarr)}</span>
</Button>
</a>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.opensonarr4k)}</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo && (
<div className="mt-8">
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
</ConfirmButton>
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
revalidate={() => revalidate()}
show={showManager}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
@ -469,7 +313,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
{prev}
<span>|</span>
{curr}
</>
))}
</span>
@ -484,13 +330,41 @@ const TvDetails: React.FC<TvDetailsProps> = ({ 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',
}
) && (
<Button
buttonType="danger"
className="ml-2 first:ml-0"
onClick={() => setShowIssueModal(true)}
>
<ExclamationIcon className="w-5" />
</Button>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<CogIcon />
<CogIcon className="!mr-0" />
{(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
</>
)}
</Button>
)}
</div>

@ -49,6 +49,8 @@ const globalMessages = defineMessages({
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
resultsperpage: 'Display {pageSize} results per page',
noresults: 'No results.',
open: 'Open',
resolved: 'Resolved',
});
export default globalMessages;

@ -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 <UserLink>{username}</UserLink>",
"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 <strong>{title}</strong> 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…",

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

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

@ -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;
}
}
}

@ -10,6 +10,7 @@ module.exports = {
'variants',
'responsive',
'screen',
'layer',
],
},
],

Loading…
Cancel
Save