mirror of
https://github.com/sct/overseerr.git
synced 2025-09-29 13:33:26 +02:00
feat: issues (#2180)
This commit is contained in:
@@ -323,7 +323,9 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
|
@@ -7,7 +7,7 @@ import Transition from '../../Transition';
|
||||
|
||||
interface SlideOverProps {
|
||||
show?: boolean;
|
||||
title: string;
|
||||
title: React.ReactNode;
|
||||
subText?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
68
src/components/IssueBlock/index.tsx
Normal file
68
src/components/IssueBlock/index.tsx
Normal 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;
|
263
src/components/IssueDetails/IssueComment/index.tsx
Normal file
263
src/components/IssueDetails/IssueComment/index.tsx
Normal 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;
|
152
src/components/IssueDetails/IssueDescription/index.tsx
Normal file
152
src/components/IssueDetails/IssueDescription/index.tsx
Normal 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;
|
600
src/components/IssueDetails/index.tsx
Normal file
600
src/components/IssueDetails/index.tsx
Normal 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;
|
257
src/components/IssueList/IssueItem/index.tsx
Normal file
257
src/components/IssueList/IssueItem/index.tsx
Normal 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;
|
256
src/components/IssueList/index.tsx
Normal file
256
src/components/IssueList/index.tsx
Normal 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;
|
303
src/components/IssueModal/CreateIssueModal/index.tsx
Normal file
303
src/components/IssueModal/CreateIssueModal/index.tsx
Normal 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;
|
34
src/components/IssueModal/constants.ts
Normal file
34
src/components/IssueModal/constants.ts
Normal 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,
|
||||
},
|
||||
];
|
36
src/components/IssueModal/index.tsx
Normal file
36
src/components/IssueModal/index.tsx
Normal 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;
|
@@ -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 (
|
||||
|
271
src/components/ManageSlideOver/index.tsx
Normal file
271
src/components/ManageSlideOver/index.tsx
Normal 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;
|
@@ -2,18 +2,15 @@ import {
|
||||
ArrowCircleRightIcon,
|
||||
CloudIcon,
|
||||
CogIcon,
|
||||
ExclamationIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
TicketIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
DocumentRemoveIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { uniqBy } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -21,6 +18,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
||||
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
|
||||
@@ -36,16 +34,14 @@ import Error from '../../pages/_error';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import Button from '../Common/Button';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import ConfirmButton from '../Common/ConfirmButton';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import IssueModal from '../IssueModal';
|
||||
import ManageSlideOver from '../ManageSlideOver';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import PersonCard from '../PersonCard';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
import RequestButton from '../RequestButton';
|
||||
import Slider from '../Slider';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
@@ -64,17 +60,8 @@ const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
similar: 'Similar Titles',
|
||||
overviewunavailable: 'Overview unavailable.',
|
||||
manageModalTitle: 'Manage Movie',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No requests.',
|
||||
manageModalClearMedia: 'Clear Media Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
studio: '{studioCount, plural, one {Studio} other {Studios}}',
|
||||
viewfullcrew: 'View Full Crew',
|
||||
openradarr: 'Open Movie in Radarr',
|
||||
openradarr4k: 'Open Movie in 4K Radarr',
|
||||
downloadstatus: 'Download Status',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play in 4K on Plex',
|
||||
markavailable: 'Mark as Available',
|
||||
@@ -97,6 +84,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const minStudios = 3;
|
||||
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<MovieDetailsType>(
|
||||
`/api/v1/movie/${router.query.movieId}`,
|
||||
@@ -164,20 +152,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const region = user?.settings?.region
|
||||
? user.settings.region
|
||||
: settings.currentSettings.region
|
||||
@@ -264,141 +238,19 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.title} />
|
||||
<SlideOver
|
||||
show={showManager}
|
||||
title={intl.formatMessage(messages.manageModalTitle)}
|
||||
<IssueModal
|
||||
onCancel={() => setShowIssueModal(false)}
|
||||
show={showIssueModal}
|
||||
mediaType="movie"
|
||||
tmdbId={data.id}
|
||||
/>
|
||||
<ManageSlideOver
|
||||
data={data}
|
||||
mediaType="movie"
|
||||
onClose={() => setShowManager(false)}
|
||||
subText={data.title}
|
||||
>
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.movie4kEnabled)) && (
|
||||
<div className="mb-6">
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.movie4kEnabled && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.mark4kavailable)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
||||
<div className="mt-8">
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.openradarr)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.openradarr4k)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo && (
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
revalidate={() => revalidate()}
|
||||
show={showManager}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
@@ -462,7 +314,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
@@ -475,13 +329,39 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
tmdbId={data.id}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
data.mediaInfo?.status4k === MediaStatus.AVAILABLE) &&
|
||||
hasPermission(
|
||||
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowIssueModal(true)}
|
||||
>
|
||||
<ExclamationIcon />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<CogIcon />
|
||||
<CogIcon className="!mr-0" />
|
||||
{(
|
||||
data.mediaInfo?.issues.filter(
|
||||
(issue) => issue.status === IssueStatus.OPEN
|
||||
) ?? []
|
||||
).length > 0 && (
|
||||
<>
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -37,6 +37,17 @@ const messages = defineMessages({
|
||||
'Send notifications when media requests are declined.',
|
||||
usermediadeclinedDescription:
|
||||
'Get notified when your media requests are declined.',
|
||||
issuecreated: 'Issue Created',
|
||||
issuecreatedDescription: 'Send notifications when new issues are created.',
|
||||
issuecomment: 'Issue Comment',
|
||||
issuecommentDescription:
|
||||
'Send notifications when issues receive new comments.',
|
||||
userissuecommentDescription:
|
||||
'Send notifications when your issue receives new comments.',
|
||||
issueresolved: 'Issue Resolved',
|
||||
issueresolvedDescription: 'Send notifications when issues are resolved.',
|
||||
userissueresolvedDescription:
|
||||
'Send notifications when your issues are resolved.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -74,6 +85,9 @@ export enum Notification {
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
ISSUE_CREATED = 256,
|
||||
ISSUE_COMMENT = 512,
|
||||
ISSUE_RESOLVED = 1024,
|
||||
}
|
||||
|
||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||
@@ -232,6 +246,35 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
value: Notification.MEDIA_FAILED,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
},
|
||||
{
|
||||
id: 'issue-created',
|
||||
name: intl.formatMessage(messages.issuecreated),
|
||||
description: intl.formatMessage(messages.issuecreatedDescription),
|
||||
value: Notification.ISSUE_CREATED,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_ISSUES),
|
||||
},
|
||||
{
|
||||
id: 'issue-comment',
|
||||
name: intl.formatMessage(messages.issuecomment),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.userissuecommentDescription
|
||||
: messages.issuecommentDescription
|
||||
),
|
||||
value: Notification.ISSUE_COMMENT,
|
||||
hasNotifyUser: true,
|
||||
},
|
||||
{
|
||||
id: 'issue-resolved',
|
||||
name: intl.formatMessage(messages.issueresolved),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.userissueresolvedDescription
|
||||
: messages.issueresolvedDescription
|
||||
),
|
||||
value: Notification.ISSUE_RESOLVED,
|
||||
hasNotifyUser: true,
|
||||
},
|
||||
];
|
||||
|
||||
const filteredTypes = types.filter(
|
||||
|
@@ -49,6 +49,12 @@ export const messages = defineMessages({
|
||||
'Grant permission to use advanced request options.',
|
||||
viewrequests: 'View Requests',
|
||||
viewrequestsDescription: "Grant permission to view other users' requests.",
|
||||
manageissues: 'Manage Issues',
|
||||
manageissuesDescription: 'Grant permission to manage Overseerr issues.',
|
||||
createissues: 'Create Issues',
|
||||
createissuesDescription: 'Grant permission to create new issues.',
|
||||
viewissues: 'View Issues',
|
||||
viewissuesDescription: "Grant permission to view other users' issues.",
|
||||
});
|
||||
|
||||
interface PermissionEditProps {
|
||||
@@ -223,6 +229,26 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'manageissues',
|
||||
name: intl.formatMessage(messages.manageissues),
|
||||
description: intl.formatMessage(messages.manageissuesDescription),
|
||||
permission: Permission.MANAGE_ISSUES,
|
||||
children: [
|
||||
{
|
||||
id: 'createissues',
|
||||
name: intl.formatMessage(messages.createissues),
|
||||
description: intl.formatMessage(messages.createissuesDescription),
|
||||
permission: Permission.CREATE_ISSUES,
|
||||
},
|
||||
{
|
||||
id: 'viewissues',
|
||||
name: intl.formatMessage(messages.viewissues),
|
||||
description: intl.formatMessage(messages.viewissuesDescription),
|
||||
permission: Permission.VIEW_ISSUES,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
@@ -104,7 +104,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}` : null
|
||||
inView ? url : null
|
||||
);
|
||||
const {
|
||||
data: requestData,
|
||||
|
@@ -1,15 +1,10 @@
|
||||
import {
|
||||
ArrowCircleRightIcon,
|
||||
CogIcon,
|
||||
ExclamationIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
DocumentRemoveIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
@@ -17,6 +12,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
|
||||
import { IssueStatus } from '../../../server/constants/issue';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import { Crew } from '../../../server/models/common';
|
||||
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
|
||||
@@ -33,16 +29,14 @@ import Error from '../../pages/_error';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import Button from '../Common/Button';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import ConfirmButton from '../Common/ConfirmButton';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import IssueModal from '../IssueModal';
|
||||
import ManageSlideOver from '../ManageSlideOver';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import PersonCard from '../PersonCard';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
import RequestButton from '../RequestButton';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Slider from '../Slider';
|
||||
@@ -58,25 +52,13 @@ const messages = defineMessages({
|
||||
similar: 'Similar Series',
|
||||
watchtrailer: 'Watch Trailer',
|
||||
overviewunavailable: 'Overview unavailable.',
|
||||
manageModalTitle: 'Manage Series',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No requests.',
|
||||
manageModalClearMedia: 'Clear Media Data',
|
||||
manageModalClearMediaWarning:
|
||||
'* This will irreversibly remove all data for this series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.',
|
||||
originaltitle: 'Original Title',
|
||||
showtype: 'Series Type',
|
||||
anime: 'Anime',
|
||||
network: '{networkCount, plural, one {Network} other {Networks}}',
|
||||
viewfullcrew: 'View Full Crew',
|
||||
opensonarr: 'Open Series in Sonarr',
|
||||
opensonarr4k: 'Open Series in 4K Sonarr',
|
||||
downloadstatus: 'Download Status',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play in 4K on Plex',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
allseasonsmarkedavailable: '* All seasons will be marked as available.',
|
||||
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
|
||||
episodeRuntime: 'Episode Runtime',
|
||||
episodeRuntimeMinutes: '{runtime} minutes',
|
||||
@@ -95,6 +77,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
const { locale } = useLocale();
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<TvDetailsType>(
|
||||
`/api/v1/tv/${router.query.tvId}`,
|
||||
@@ -156,20 +139,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data?.mediaInfo?.id) {
|
||||
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
await axios.post(`/api/v1/media/${data?.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
});
|
||||
revalidate();
|
||||
};
|
||||
|
||||
const region = user?.settings?.region
|
||||
? user.settings.region
|
||||
: settings.currentSettings.region
|
||||
@@ -261,6 +230,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
</div>
|
||||
)}
|
||||
<PageTitle title={data.name} />
|
||||
<IssueModal
|
||||
onCancel={() => setShowIssueModal(false)}
|
||||
show={showIssueModal}
|
||||
mediaType="tv"
|
||||
tmdbId={data.id}
|
||||
/>
|
||||
<RequestModal
|
||||
tmdbId={data.id}
|
||||
show={showRequestModal}
|
||||
@@ -271,144 +246,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
}}
|
||||
onCancel={() => setShowRequestModal(false)}
|
||||
/>
|
||||
<SlideOver
|
||||
show={showManager}
|
||||
title={intl.formatMessage(messages.manageModalTitle)}
|
||||
<ManageSlideOver
|
||||
data={data}
|
||||
mediaType="tv"
|
||||
onClose={() => setShowManager(false)}
|
||||
subText={data.name}
|
||||
>
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.downloadstatus)}
|
||||
</h3>
|
||||
<div className="mb-6 overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<li
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
(data.mediaInfo.status !== MediaStatus.AVAILABLE ||
|
||||
(data.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled)) && (
|
||||
<div className="mb-6">
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable()}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo &&
|
||||
data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
|
||||
settings.currentSettings.series4kEnabled && (
|
||||
<div className="flex flex-col mb-2 sm:flex-row flex-nowrap">
|
||||
<Button
|
||||
onClick={() => markAvailable(true)}
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.mark4kavailable)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.allseasonsmarkedavailable)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.requests?.map((request) => (
|
||||
<li
|
||||
key={`manage-request-${request.id}`}
|
||||
className="border-b border-gray-700 last:border-b-0"
|
||||
>
|
||||
<RequestBlock request={request} onUpdate={() => revalidate()} />
|
||||
</li>
|
||||
))}
|
||||
{(data.mediaInfo?.requests ?? []).length === 0 && (
|
||||
<li className="py-4 text-center text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalNoRequests)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && (
|
||||
<div className="mt-8">
|
||||
{data?.mediaInfo?.serviceUrl && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.opensonarr)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{data?.mediaInfo?.serviceUrl4k && (
|
||||
<a
|
||||
href={data?.mediaInfo?.serviceUrl4k}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.opensonarr4k)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data?.mediaInfo && (
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
revalidate={() => revalidate()}
|
||||
show={showManager}
|
||||
/>
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
@@ -469,7 +313,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
{prev}
|
||||
<span>|</span>
|
||||
{curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
@@ -484,13 +330,41 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
isShowComplete={isComplete}
|
||||
is4kShowComplete={is4kComplete}
|
||||
/>
|
||||
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
data.mediaInfo?.status4k === MediaStatus.AVAILABLE ||
|
||||
data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
data?.mediaInfo?.status4k === MediaStatus.PARTIALLY_AVAILABLE) &&
|
||||
hasPermission(
|
||||
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowIssueModal(true)}
|
||||
>
|
||||
<ExclamationIcon className="w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<CogIcon />
|
||||
<CogIcon className="!mr-0" />
|
||||
{(
|
||||
data.mediaInfo?.issues.filter(
|
||||
(issue) => issue.status === IssueStatus.OPEN
|
||||
) ?? []
|
||||
).length > 0 && (
|
||||
<>
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1" />
|
||||
<div className="absolute w-3 h-3 bg-red-600 rounded-full -right-1 -top-1 animate-ping" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user