You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
665 lines
24 KiB
665 lines
24 KiB
import {
|
|
ChatIcon,
|
|
CheckCircleIcon,
|
|
ExclamationIcon,
|
|
PlayIcon,
|
|
ServerIcon,
|
|
} from '@heroicons/react/outline';
|
|
import { RefreshIcon } from '@heroicons/react/solid';
|
|
import axios from 'axios';
|
|
import { Field, Form, Formik } from 'formik';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/router';
|
|
import React, { useState } from 'react';
|
|
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
|
import { useToasts } from 'react-toast-notifications';
|
|
import useSWR from 'swr';
|
|
import * as Yup from 'yup';
|
|
import { IssueStatus } from '../../../server/constants/issue';
|
|
import { MediaType } from '../../../server/constants/media';
|
|
import type Issue from '../../../server/entity/Issue';
|
|
import type { MovieDetails } from '../../../server/models/Movie';
|
|
import type { TvDetails } from '../../../server/models/Tv';
|
|
import { Permission, useUser } from '../../hooks/useUser';
|
|
import globalMessages from '../../i18n/globalMessages';
|
|
import Error from '../../pages/_error';
|
|
import Badge from '../Common/Badge';
|
|
import Button from '../Common/Button';
|
|
import CachedImage from '../Common/CachedImage';
|
|
import LoadingSpinner from '../Common/LoadingSpinner';
|
|
import Modal from '../Common/Modal';
|
|
import PageTitle from '../Common/PageTitle';
|
|
import { issueOptions } from '../IssueModal/constants';
|
|
import Transition from '../Transition';
|
|
import IssueComment from './IssueComment';
|
|
import IssueDescription from './IssueDescription';
|
|
|
|
const messages = defineMessages({
|
|
openedby: '#{issueId} opened {relativeTime} by {username}',
|
|
closeissue: 'Close Issue',
|
|
closeissueandcomment: 'Close with Comment',
|
|
leavecomment: 'Comment',
|
|
comments: 'Comments',
|
|
reopenissue: 'Reopen Issue',
|
|
reopenissueandcomment: 'Reopen with Comment',
|
|
issuepagetitle: 'Issue',
|
|
playonplex: 'Play on Plex',
|
|
play4konplex: 'Play in 4K on Plex',
|
|
openinarr: 'Open in {arr}',
|
|
openin4karr: 'Open in 4K {arr}',
|
|
toasteditdescriptionsuccess: 'Issue description edited successfully!',
|
|
toasteditdescriptionfailed:
|
|
'Something went wrong while editing the issue description.',
|
|
toaststatusupdated: 'Issue status updated successfully!',
|
|
toaststatusupdatefailed:
|
|
'Something went wrong while updating the issue status.',
|
|
issuetype: 'Type',
|
|
lastupdated: 'Last Updated',
|
|
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 successfully!',
|
|
toastissuedeletefailed: 'Something went wrong while deleting the issue.',
|
|
nocomments: 'No comments.',
|
|
unknownissuetype: 'Unknown',
|
|
commentplaceholder: 'Add a comment…',
|
|
});
|
|
|
|
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, mutate: 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
|
|
);
|
|
|
|
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), title]} />
|
|
<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="media-header">
|
|
<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="warning">
|
|
{intl.formatMessage(globalMessages.open)}
|
|
</Badge>
|
|
)}
|
|
{issueData.status === IssueStatus.RESOLVED && (
|
|
<Badge badgeType="success">
|
|
{intl.formatMessage(globalMessages.resolved)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<h1>
|
|
<Link
|
|
href={`/${
|
|
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
|
|
}/${data.id}`}
|
|
>
|
|
<a className="hover:underline">{title}</a>
|
|
</Link>{' '}
|
|
{releaseYear && (
|
|
<span className="media-year">({releaseYear.slice(0, 4)})</span>
|
|
)}
|
|
</h1>
|
|
<span className="media-attributes">
|
|
{intl.formatMessage(messages.openedby, {
|
|
issueId: issueData.id,
|
|
username: (
|
|
<Link
|
|
href={
|
|
belongsToUser
|
|
? '/profile'
|
|
: `/users/${issueData.createdBy.id}`
|
|
}
|
|
>
|
|
<a className="inline-flex items-center h-full ml-1 xl:ml-1.5 group">
|
|
<img
|
|
className="w-5 h-5 mr-0.5 transition duration-300 scale-100 rounded-full xl:w-6 xl:h-6 xl:mr-1 transform-gpu group-hover:scale-105"
|
|
src={issueData.createdBy.avatar}
|
|
alt=""
|
|
/>
|
|
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
|
|
{issueData.createdBy.displayName}
|
|
</span>
|
|
</a>
|
|
</Link>
|
|
),
|
|
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
|
|
description={firstComment.message}
|
|
belongsToUser={belongsToUser}
|
|
commentCount={otherComments.length}
|
|
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.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>
|
|
<div className="flex flex-col mt-4 mb-6 space-y-2">
|
|
{issueData?.media.plexUrl && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.plexUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<PlayIcon />
|
|
<span>{intl.formatMessage(messages.playonplex)}</span>
|
|
</Button>
|
|
)}
|
|
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.serviceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<ServerIcon />
|
|
<span>
|
|
{intl.formatMessage(messages.openinarr, {
|
|
arr:
|
|
issueData.media.mediaType === MediaType.MOVIE
|
|
? 'Radarr'
|
|
: 'Sonarr',
|
|
})}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{issueData?.media.plexUrl4k && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.plexUrl4k}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<PlayIcon />
|
|
<span>{intl.formatMessage(messages.play4konplex)}</span>
|
|
</Button>
|
|
)}
|
|
{issueData?.media.serviceUrl4k &&
|
|
hasPermission(Permission.ADMIN) && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.serviceUrl4k}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<ServerIcon />
|
|
<span>
|
|
{intl.formatMessage(messages.openin4karr, {
|
|
arr:
|
|
issueData.media.mediaType === MediaType.MOVIE
|
|
? 'Radarr'
|
|
: 'Sonarr',
|
|
})}
|
|
</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={intl.formatMessage(
|
|
messages.commentplaceholder
|
|
)}
|
|
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>
|
|
{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>
|
|
<div className="flex flex-col mt-4 mb-6 space-y-2">
|
|
{issueData?.media.plexUrl && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.plexUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<PlayIcon />
|
|
<span>{intl.formatMessage(messages.playonplex)}</span>
|
|
</Button>
|
|
)}
|
|
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.serviceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<ServerIcon />
|
|
<span>
|
|
{intl.formatMessage(messages.openinarr, {
|
|
arr:
|
|
issueData.media.mediaType === MediaType.MOVIE
|
|
? 'Radarr'
|
|
: 'Sonarr',
|
|
})}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
{issueData?.media.plexUrl4k && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.plexUrl4k}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<PlayIcon />
|
|
<span>{intl.formatMessage(messages.play4konplex)}</span>
|
|
</Button>
|
|
)}
|
|
{issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && (
|
|
<Button
|
|
as="a"
|
|
href={issueData?.media.serviceUrl4k}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="w-full"
|
|
buttonType="ghost"
|
|
>
|
|
<ServerIcon />
|
|
<span>
|
|
{intl.formatMessage(messages.openin4karr, {
|
|
arr:
|
|
issueData.media.mediaType === MediaType.MOVIE
|
|
? 'Radarr'
|
|
: 'Sonarr',
|
|
})}
|
|
</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default IssueDetails;
|