feat: issues (#2180)
parent
6565c7dd9b
commit
e402c42aaa
@ -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;
|
@ -0,0 +1,6 @@
|
|||||||
|
import Issue from '../../entity/Issue';
|
||||||
|
import { PaginatedResponse } from './common';
|
||||||
|
|
||||||
|
export interface IssueResultsResponse extends PaginatedResponse {
|
||||||
|
results: Issue[];
|
||||||
|
}
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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;
|
@ -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;
|
Loading…
Reference in new issue