feat: issues (#2180)

This commit is contained in:
Ryan Cohen
2021-10-24 21:44:20 +09:00
committed by GitHub
parent 6565c7dd9b
commit e402c42aaa
45 changed files with 4260 additions and 937 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import {
ClockIcon,
CogIcon,
ExclamationIcon,
SparklesIcon,
UsersIcon,
XIcon,
@@ -17,6 +18,7 @@ import VersionStatus from '../VersionStatus';
const messages = defineMessages({
dashboard: 'Discover',
requests: 'Requests',
issues: 'Issues',
users: 'Users',
settings: 'Settings',
});
@@ -33,6 +35,7 @@ interface SidebarLinkProps {
activeRegExp: RegExp;
as?: string;
requiredPermission?: Permission | Permission[];
permissionType?: 'and' | 'or';
}
const SidebarLinks: SidebarLinkProps[] = [
@@ -48,6 +51,20 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <ClockIcon className="w-6 h-6 mr-3" />,
activeRegExp: /^\/requests/,
},
{
href: '/issues',
messagesKey: 'issues',
svgIcon: (
<ExclamationIcon className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
activeRegExp: /^\/issues/,
requiredPermission: [
Permission.MANAGE_ISSUES,
Permission.CREATE_ISSUES,
Permission.VIEW_ISSUES,
],
permissionType: 'or',
},
{
href: '/users',
messagesKey: 'users',
@@ -121,7 +138,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<nav className="flex-1 px-4 mt-16 space-y-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
? hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
: true
).map((sidebarLink) => {
return (
@@ -188,7 +207,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<nav className="flex-1 px-4 mt-16 space-y-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
? hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
: true
).map((sidebarLink) => {
return (

View File

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

View File

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

View File

@@ -37,6 +37,17 @@ const messages = defineMessages({
'Send notifications when media requests are declined.',
usermediadeclinedDescription:
'Get notified when your media requests are declined.',
issuecreated: 'Issue Created',
issuecreatedDescription: 'Send notifications when new issues are created.',
issuecomment: 'Issue Comment',
issuecommentDescription:
'Send notifications when issues receive new comments.',
userissuecommentDescription:
'Send notifications when your issue receives new comments.',
issueresolved: 'Issue Resolved',
issueresolvedDescription: 'Send notifications when issues are resolved.',
userissueresolvedDescription:
'Send notifications when your issues are resolved.',
});
export const hasNotificationType = (
@@ -74,6 +85,9 @@ export enum Notification {
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
ISSUE_CREATED = 256,
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
}
export const ALL_NOTIFICATIONS = Object.values(Notification)
@@ -232,6 +246,35 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
value: Notification.MEDIA_FAILED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'issue-created',
name: intl.formatMessage(messages.issuecreated),
description: intl.formatMessage(messages.issuecreatedDescription),
value: Notification.ISSUE_CREATED,
hidden: user && !hasPermission(Permission.MANAGE_ISSUES),
},
{
id: 'issue-comment',
name: intl.formatMessage(messages.issuecomment),
description: intl.formatMessage(
user
? messages.userissuecommentDescription
: messages.issuecommentDescription
),
value: Notification.ISSUE_COMMENT,
hasNotifyUser: true,
},
{
id: 'issue-resolved',
name: intl.formatMessage(messages.issueresolved),
description: intl.formatMessage(
user
? messages.userissueresolvedDescription
: messages.issueresolvedDescription
),
value: Notification.ISSUE_RESOLVED,
hasNotifyUser: true,
},
];
const filteredTypes = types.filter(

View File

@@ -49,6 +49,12 @@ export const messages = defineMessages({
'Grant permission to use advanced request options.',
viewrequests: 'View Requests',
viewrequestsDescription: "Grant permission to view other users' requests.",
manageissues: 'Manage Issues',
manageissuesDescription: 'Grant permission to manage Overseerr issues.',
createissues: 'Create Issues',
createissuesDescription: 'Grant permission to create new issues.',
viewissues: 'View Issues',
viewissuesDescription: "Grant permission to view other users' issues.",
});
interface PermissionEditProps {
@@ -223,6 +229,26 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
},
],
},
{
id: 'manageissues',
name: intl.formatMessage(messages.manageissues),
description: intl.formatMessage(messages.manageissuesDescription),
permission: Permission.MANAGE_ISSUES,
children: [
{
id: 'createissues',
name: intl.formatMessage(messages.createissues),
description: intl.formatMessage(messages.createissuesDescription),
permission: Permission.CREATE_ISSUES,
},
{
id: 'viewissues',
name: intl.formatMessage(messages.viewissues),
description: intl.formatMessage(messages.viewissuesDescription),
permission: Permission.VIEW_ISSUES,
},
],
},
];
return (

View File

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

View File

@@ -1,15 +1,10 @@
import {
ArrowCircleRightIcon,
CogIcon,
ExclamationIcon,
FilmIcon,
PlayIcon,
} from '@heroicons/react/outline';
import {
CheckCircleIcon,
DocumentRemoveIcon,
ExternalLinkIcon,
} from '@heroicons/react/solid';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useMemo, useState } from 'react';
@@ -17,6 +12,7 @@ import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import { Crew } from '../../../server/models/common';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
@@ -33,16 +29,14 @@ import Error from '../../pages/_error';
import { sortCrewPriority } from '../../utils/creditHelpers';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import ConfirmButton from '../Common/ConfirmButton';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
import SlideOver from '../Common/SlideOver';
import DownloadBlock from '../DownloadBlock';
import ExternalLinkBlock from '../ExternalLinkBlock';
import IssueModal from '../IssueModal';
import ManageSlideOver from '../ManageSlideOver';
import MediaSlider from '../MediaSlider';
import PersonCard from '../PersonCard';
import RequestBlock from '../RequestBlock';
import RequestButton from '../RequestButton';
import RequestModal from '../RequestModal';
import Slider from '../Slider';
@@ -58,25 +52,13 @@ const messages = defineMessages({
similar: 'Similar Series',
watchtrailer: 'Watch Trailer',
overviewunavailable: 'Overview unavailable.',
manageModalTitle: 'Manage Series',
manageModalRequests: 'Requests',
manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear Media Data',
manageModalClearMediaWarning:
'* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
originaltitle: 'Original Title',
showtype: 'Series Type',
anime: 'Anime',
network: '{networkCount, plural, one {Network} other {Networks}}',
viewfullcrew: 'View Full Crew',
opensonarr: 'Open Series in Sonarr',
opensonarr4k: 'Open Series in 4K Sonarr',
downloadstatus: 'Download Status',
playonplex: 'Play on Plex',
play4konplex: 'Play in 4K on Plex',
markavailable: 'Mark as Available',
mark4kavailable: 'Mark as Available in 4K',
allseasonsmarkedavailable: '* All seasons will be marked as available.',
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
episodeRuntime: 'Episode Runtime',
episodeRuntimeMinutes: '{runtime} minutes',
@@ -95,6 +77,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const { locale } = useLocale();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false);
const { data, error, revalidate } = useSWR<TvDetailsType>(
`/api/v1/tv/${router.query.tvId}`,
@@ -156,20 +139,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
});
}
const deleteMedia = async () => {
if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
revalidate();
}
};
const markAvailable = async (is4k = false) => {
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
is4k,
});
revalidate();
};
const region = user?.settings?.region
? user.settings.region
: settings.currentSettings.region
@@ -261,6 +230,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
)}
<PageTitle title={data.name} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="tv"
tmdbId={data.id}
/>
<RequestModal
tmdbId={data.id}
show={showRequestModal}
@@ -271,144 +246,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}}
onCancel={() => setShowRequestModal(false)}
/>
<SlideOver
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
<ManageSlideOver
data={data}
mediaType="tv"
onClose={() => setShowManager(false)}
subText={data.name}
>
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} />
</li>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<li
key={`dl-status-${status.externalId}-${index}`}
className="border-b border-gray-700 last:border-b-0"
>
<DownloadBlock downloadItem={status} is4k />
</li>
))}
</ul>
</div>
</>
)}
{data?.mediaInfo &&
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled)) && (
<div className="mb-6">
{data?.mediaInfo &&
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable()}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>{intl.formatMessage(messages.markavailable)}</span>
</Button>
</div>
)}
{data?.mediaInfo &&
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
<Button
onClick={() => markAvailable(true)}
className="w-full sm:mb-0"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(messages.mark4kavailable)}
</span>
</Button>
</div>
)}
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.allseasonsmarkedavailable)}
</div>
</div>
)}
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
<ul>
{data.mediaInfo?.requests?.map((request) => (
<li
key={`manage-request-${request.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<RequestBlock request={request} onUpdate={() => revalidate()} />
</li>
))}
{(data.mediaInfo?.requests ?? []).length === 0 && (
<li className="py-4 text-center text-gray-400">
{intl.formatMessage(messages.manageModalNoRequests)}
</li>
)}
</ul>
</div>
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
<div className="mt-8">
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block mb-2 last:mb-0"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.opensonarr)}</span>
</Button>
</a>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
>
<Button buttonType="ghost" className="w-full">
<ExternalLinkIcon />
<span>{intl.formatMessage(messages.opensonarr4k)}</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo && (
<div className="mt-8">
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
</ConfirmButton>
<div className="mt-3 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning)}
</div>
</div>
)}
</SlideOver>
revalidate={() => revalidate()}
show={showManager}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
@@ -469,7 +313,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
{prev}
<span>|</span>
{curr}
</>
))}
</span>
@@ -484,13 +330,41 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
isShowComplete={isComplete}
is4kShowComplete={is4kComplete}
/>
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
data.mediaInfo?.status4k === MediaStatus.AVAILABLE ||
data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE ||
data?.mediaInfo?.status4k === MediaStatus.PARTIALLY_AVAILABLE) &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Button
buttonType="danger"
className="ml-2 first:ml-0"
onClick={() => setShowIssueModal(true)}
>
<ExclamationIcon className="w-5" />
</Button>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<CogIcon />
<CogIcon className="!mr-0" />
{(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
</>
)}
</Button>
)}
</div>