Merge branch 'develop'

This commit is contained in:
Fallenbagel
2022-05-21 06:43:52 +05:00
315 changed files with 29562 additions and 14858 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 331.60596 331.60595"><g transform="translate(-92.2 -101.57)" fill="currentColor" stroke="currentColor" stroke-width="2"><path d="m317.7 376.2c6.2-1.7 15.8 0 19.5 3.2 5.5 4.8 4.9 20.9 1.1 29-0.8 1.7-1.8 3.4-3.2 4.4s-3.4 1.2-4.7 0-1.7-3.3-1.9-5.2c-0.2-3.1-0.2-6.2 0.3-9.2 0.2-1.3 0.2-2.9-0.2-4.2-0.6-2.2-2.5-3.1-4.5-3.5-3.4-0.8-14.3-0.7-19.5-0.7-0.8 0 1.5-9.1 2-9.7 2.6-2.9 7.5-3.1 11.1-4.1z"/><path d="m258.9 119.7l-9-2.7c-4.6-1.4-9.2-2.8-14-2.5-2.8 0.2-6.1 1.3-6.9 4-0.6 2-1.6 7.3-1.3 7.9 1.5 3.4 13.9 6.7 18.3 6.7"/><path d="m392.6 177.9c-1.4 1.4-2.2 3.5-2.5 5.5-0.2 1.4-0.1 3 0.5 4.3s1.8 2.3 3.1 3c1.3 0.6 2.8 0.9 4.3 0.9 1.1 0 2.3-0.1 3.1-0.9 0.6-0.7 0.8-1.6 0.9-2.5 0.2-2.3-0.1-4.7-0.9-6.9-0.4-1.1-0.9-2.3-1.8-3.1-1.7-1.8-4.5-2.2-6.4-0.5-0.1 0-0.2 0.1-0.3 0.2z"/><path d="m358.5 164.2c-1-1 0-2.7 1-3.7 5.8-5.2 15.1-4.6 21.8-0.6 10.9 6.6 15.6 19.9 17.2 32.5 0.6 5.2 0.9 10.6-0.5 15.7s-4.6 9.9-9.3 12.1c-1.1 0.5-2.3 0.9-3.4 0.5s-1.9-1.8-1.2-2.8c-9.4-13.6-19-26.8-20.9-43.2-0.5-4.1-1.8-7.4-4.7-10.5z"/><path d="m134.7 328.4c-5.1-3.1-9.9-6.6-14.3-10.6-1.3-1.2-2.6-2.5-2.6-4.3 0-1.2 0.6-2.2 1.2-3.2 0.8-1.4 1.7-2.8 2.5-4.1 1.1-1.8 2.9-3.9 4.9-3.2 0.9 0.3 1.5 1.1 2 1.8 2.4 3.3 4.9 6.6 7.3 9.8 1.5 2 3.7 4.3 6.1 3.5"/><path d="m209.6 133c33.2-18 77.8-19.6 111.5-8.7 24.3 7.9 43.4 26.7 53.3 50 8.7 20.6 10.5 43.6 8.1 65.7-4.4 40.2-20.2 77.9-40.3 112.6-11.1 19-21.8 36-40.5 48.5-36.8 24.6-87.2 22.1-128.4 11.5-19.9-5.1-39.7-17.3-47.2-37.3-4.8-12.8-4.2-27.6 1.5-40 11.6-24.8 43.2-38.4 45.6-67.9 0.7-8.7-1.6-17.3-3.6-25.7-5.6-23.4-8.9-45.8 1.4-68.7 8.1-17.7 21.9-31 38.6-40z"/><path d="m189.8 151.4c-5.4-5.2-11.9-8.8-19-10.3-2.2-0.5-4.7-0.7-6.9 0.7-1.8 1.2-3.1 3.3-4.2 5.3-1.6 3-3 6.2-4.1 9.4-0.4 1.2-0.6 2.5 0 3.5 0.3 0.6 0.9 0.9 1.5 1.2 8.1 4.2 16.8 7.1 25.5 9.8"/><path d="m183.7 158.7c-2.5-1.8-16.8-12.1-18.7-4.8-0.4 1.6 0.5 3.9 1.5 4.8"/><path d="m264.5 174.9c-0.5 0.5-0.9 1-1.3 1.6-9 11.6-12 27.9-9.3 42.1 1.7 9 5.9 17.9 13.2 23.4 19.3 14.6 51.5 13.5 68.4-1.5 24.4-21.7 13-67.6-14-78.8-17.6-7.2-43.7-1.6-57 13.2z" fill-opacity=".97633"/><path d="m382.1 237.1c1.4-0.1 2.9-0.1 4.3 0.1 0.3 0 0.7 0.1 1 0.4 0.2 0.3 0.4 0.7 0.5 1.1 1 3.9 0.5 8.2 0.1 12.4-0.1 0.9-0.2 1.8-0.6 2.6-1 2.1-3.1 2.7-4.7 2.7-0.1 0-0.2 0-0.3-0.1-0.3-0.2-0.3-0.7-0.2-1.2 0.3-5.9-0.1-11.9-0.1-18v0z"/><path d="m378.7 236.8c-1.4 0.4-2.5 2-2.8 4.4-0.5 4.4-0.7 8.9-0.5 13.4 0 0.9 0.1 1.9 0.5 2.4 0.2 0.3 0.5 0.4 0.8 0.4 1.6 0.3 4.1-0.6 5.6-1 0 0 0-5.2-0.1-8s-0.1-6.1-0.2-8.9v-2.2c0.1-0.7-2.6-0.7-3.3-0.5z"/><path d="m358.3 231.8c-0.3 2.2 0.1 4.7 1.7 7.4 2.6 4.4 7 6.1 11.9 5.8 8.9-0.6 25.3-5.4 27.5-15.7 0.6-3-0.3-6.1-2.2-8.5-6.2-7.8-17.8-5.7-25.6-2-5.9 2.7-12.4 7-13.3 13z"/><path d="m386.4 208.6c2.2 1.4 3.7 3.8 4 7 0.3 3.6-1.4 7.5-5 8.8-2.9 1.1-6.2 0.6-9.1-0.4s-5.8-2.8-6.8-5.7c-0.7-2-0.3-4.3 0.7-6.1 1.1-1.8 2.8-3.2 4.7-4.1 3.9-1.8 8.4-1.6 11.5 0.5z"/><path d="m414.7 262.6c2.4 0.6 4.8 2.1 5.6 4.4s0.1 4.9-1.6 6.7-4.2 2.5-6.6 2.5c-0.8 0-1.7-0.1-2.4-0.5-2.5-1.1-3.5-4-4.2-6.6-1.8-6.8 3.6-7.8 9.2-6.5z"/><path d="m267.1 284.7c2.3-4.5 141.3-36.2 144.7-31.6 3.4 4.5 15.8 88.2 9 90.4-6.8 2.3-119.8 37.3-126.6 35s-29.4-89.3-27.1-93.8z"/><path d="m294.2 378.5s54.3-74.6 59.9-76.9c5.7-2.3 67.3 41.3 67.3 41.3"/><path d="m267 287.7s86 38.8 91.6 36.6c5.7-2.3 53.1-71.2 53.1-71.2"/><path d="m132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 0.2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7 0.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"/><path d="m132.8 375.6c-3.5 3.8-7.3 7.8-13 9.2-4.6 1.2-10 0.2-13.6-2.3-1.4-1-2.6-2.2-4-3.2-1.5-1-3.4-1.7-5.3-1.3-2.7 0.5-4.1 3.1-3.6 5.3 2 8.8 17 15.6 27.5 15.5 9 0 19-4.6 21.4-11.8"/><path d="m261.9 283.5c-0.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-0.4 2.9-0.8 4.2-0.2 1.8 0.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"/><path d="m261.9 283.5c-0.1 4.2 4.3 7.3 8.4 7.6s8.2-1.3 12.2-2.6c1.4-0.4 2.9-0.8 4.2-0.2 1.8 0.9 2.7 4.1 1.8 5.9s-3.4 3.5-5.3 4.4c-6.5 3-12.9 3.6-19.9 2-5.3-1.2-11.3-4.3-13-13.5"/><path d="m318.4 198.4c-2-0.3-4.1 0.1-5.9 1.3-3.2 2.1-4.7 6.2-4.7 9.9 0 1.9 0.4 3.8 1.4 5.3 1.2 1.7 3.1 2.9 5.2 3.4 3.4 0.8 8.2 0.7 10.5-2.5 1-1.5 1.4-3.3 1.5-5.1 0.5-5.7-1.8-11.4-8-12.3z"/><path d="m320.4 203.3c0.9 0.3 1.7 0.8 2.1 1.7 0.4 0.8 0.4 1.7 0.3 2.5-0.1 1-0.6 2-1.5 2.7-0.7 0.5-1.7 0.7-2.6 0.5s-1.7-0.8-2.2-1.6c-1.1-1.6-0.9-4.4 0.9-5.5 0.9-0.4 2-0.6 3-0.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 144.8 144.8" xmlns="http://www.w3.org/2000/svg"><circle cx="72.4" cy="72.4" r="72.4" fill="#fff"/><path d="M29.5,111.8c10.6,11.6,25.9,18.8,42.9,18.8c8.7,0,16.9-1.9,24.3-5.3L56.3,85L29.5,111.8z" fill="#ED2224"/><path d="m56.1 60.6l-30.6 30.5-4.1-4.1 32.2-32.2 37.6-37.6c-5.9-2-12.2-3.1-18.8-3.1-32.2 0-58.3 26.1-58.3 58.3 0 13.1 4.3 25.2 11.7 35l30.5-30.5 2.1 2 43.7 43.7c0.9-0.5 1.7-1 2.5-1.6l-48.3-48.3-29.3 29.3-4.1-4.1 33.4-33.4 2.1 2 51 50.9c0.8-0.6 1.5-1.3 2.2-1.9l-55-55-0.5 0.1z" fill="#ED2224"/><path d="m115.7 111.4c9.3-10.3 15-24 15-39 0-23.4-13.8-43.5-33.6-52.8l-36.7 36.6 55.3 55.2zm-41.2-44.6l-4.1-4.1 28.9-28.9 4.1 4.1-28.9 28.9zm27.4-39.7l-33.3 33.3-4.1-4.1 33.3-33.3 4.1 4.1z" fill="#ED1C24"/><path d="m72.4 144.8c-39.9 0-72.4-32.5-72.4-72.4s32.5-72.4 72.4-72.4 72.4 32.5 72.4 72.4-32.5 72.4-72.4 72.4zm0-137.5c-35.9 0-65.1 29.2-65.1 65.1s29.2 65.1 65.1 65.1 65.1-29.2 65.1-65.1-29.2-65.1-65.1-65.1z" fill="#ED2224"/></svg>

After

Width:  |  Height:  |  Size: 957 B

View File

@@ -1,14 +1,11 @@
import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
import axios from 'axios';
import { DownloadIcon } from '@heroicons/react/outline';
import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { Collection } from '../../../server/models/Collection';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
@@ -17,23 +14,17 @@ import Error from '../../pages/_error';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import PageTitle from '../Common/PageTitle';
import RequestModal from '../RequestModal';
import Slider from '../Slider';
import StatusBadge from '../StatusBadge';
import TitleCard from '../TitleCard';
import Transition from '../Transition';
const messages = defineMessages({
overview: 'Overview',
numberofmovies: '{count} Movies',
requestcollection: 'Request Collection',
requestswillbecreated:
'The following titles will have requests created for them:',
requestcollection4k: 'Request Collection in 4K',
requestswillbecreated4k:
'The following titles will have 4K requests created for them:',
requestSuccess: '<strong>{title}</strong> requested successfully!',
});
interface CollectionDetailsProps {
@@ -46,19 +37,18 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
const intl = useIntl();
const router = useRouter();
const settings = useSettings();
const { addToast } = useToasts();
const { hasPermission } = useUser();
const [requestModal, setRequestModal] = useState(false);
const [isRequesting, setRequesting] = useState(false);
const [is4k, setIs4k] = useState(false);
const { data, error, revalidate } = useSWR<Collection>(
`/api/v1/collection/${router.query.collectionId}`,
{
initialData: collection,
revalidateOnMount: true,
}
);
const {
data,
error,
mutate: revalidate,
} = useSWR<Collection>(`/api/v1/collection/${router.query.collectionId}`, {
fallbackData: collection,
revalidateOnMount: true,
});
const { data: genres } =
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
@@ -124,48 +114,6 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
).length > 0;
const requestableParts = data.parts.filter(
(part) =>
!part.mediaInfo ||
part.mediaInfo[is4k ? 'status4k' : 'status'] === MediaStatus.UNKNOWN
);
const requestBundle = async () => {
try {
setRequesting(true);
await Promise.all(
requestableParts.map(async (part) => {
await axios.post<MediaRequest>('/api/v1/request', {
mediaId: part.id,
mediaType: 'movie',
is4k,
});
})
);
addToast(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} catch (e) {
addToast('Something went wrong requesting the collection.', {
appearance: 'error',
autoDismiss: true,
});
} finally {
setRequesting(false);
setRequestModal(false);
revalidate();
}
};
const collectionAttributes: React.ReactNode[] = [];
collectionAttributes.push(
@@ -229,53 +177,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</div>
)}
<PageTitle title={data.name} />
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
<RequestModal
tmdbId={data.id}
show={requestModal}
>
<Modal
onOk={() => requestBundle()}
okText={
isRequesting
? intl.formatMessage(globalMessages.requesting)
: intl.formatMessage(
is4k ? globalMessages.request4k : globalMessages.request
)
}
okDisabled={isRequesting}
okButtonType="primary"
onCancel={() => setRequestModal(false)}
title={intl.formatMessage(
is4k ? messages.requestcollection4k : messages.requestcollection
)}
iconSvg={<DuplicateIcon />}
>
<p>
{intl.formatMessage(
is4k
? messages.requestswillbecreated4k
: messages.requestswillbecreated
)}
</p>
<ul className="py-4 pl-8 list-disc">
{data.parts
.filter(
(part) =>
!part.mediaInfo ||
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN
)
.map((part) => (
<li key={`request-part-${part.id}`}>{part.title}</li>
))}
</ul>
</Modal>
</Transition>
type="collection"
is4k={is4k}
onComplete={() => {
revalidate();
setRequestModal(false);
}}
onCancel={() => setRequestModal(false)}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
@@ -323,7 +235,9 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
{prev}
<span>|</span>
{curr}
</>
))}
</span>

View File

@@ -3,7 +3,7 @@ import { useState } from 'react';
import AnimateHeight from 'react-animate-height';
export interface AccordionProps {
children: (args: AccordionChildProps) => React.ReactElement | null;
children: (args: AccordionChildProps) => React.ReactElement<any, any> | null;
/** If true, only one accordion item can be open at any time */
single?: boolean;
/** If true, at least one accordion item will always be open */
@@ -13,7 +13,7 @@ export interface AccordionProps {
export interface AccordionChildProps {
openIndexes: number[];
handleClick(index: number): void;
AccordionContent: typeof AccordionContent;
AccordionContent: any;
}
export const AccordionContent: React.FC<{ isOpen: boolean }> = ({

View File

@@ -15,7 +15,7 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
bgColor: 'bg-yellow-600',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: <ExclamationIcon className="w-5 h-5" />,
svg: <ExclamationIcon className="h-5 w-5" />,
};
switch (type) {
@@ -24,7 +24,7 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
bgColor: 'bg-indigo-600',
titleColor: 'text-indigo-100',
textColor: 'text-indigo-300',
svg: <InformationCircleIcon className="w-5 h-5" />,
svg: <InformationCircleIcon className="h-5 w-5" />,
};
break;
case 'error':
@@ -32,13 +32,13 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
bgColor: 'bg-red-600',
titleColor: 'text-red-100',
textColor: 'text-red-300',
svg: <XCircleIcon className="w-5 h-5" />,
svg: <XCircleIcon className="h-5 w-5" />,
};
break;
}
return (
<div className={`rounded-md p-4 mb-4 ${design.bgColor}`}>
<div className={`mb-4 rounded-md p-4 ${design.bgColor}`}>
<div className="flex">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3">
@@ -48,7 +48,7 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
</div>
)}
{children && (
<div className={`mt-2 first:mt-0 text-sm ${design.textColor}`}>
<div className={`mt-2 text-sm first:mt-0 ${design.textColor}`}>
{children}
</div>
)}

View File

@@ -1,38 +1,78 @@
import Link from 'next/link';
import React from 'react';
interface BadgeProps {
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
className?: string;
href?: string;
}
const Badge: React.FC<BadgeProps> = ({
badgeType = 'default',
className,
href,
children,
}) => {
const badgeStyle = [
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default',
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap',
];
if (href) {
badgeStyle.push('transition cursor-pointer !no-underline');
} else {
badgeStyle.push('cursor-default');
}
switch (badgeType) {
case 'danger':
badgeStyle.push('bg-red-600 text-red-100');
badgeStyle.push('bg-red-600 !text-red-100');
if (href) {
badgeStyle.push('hover:bg-red-500');
}
break;
case 'warning':
badgeStyle.push('bg-yellow-500 text-yellow-100');
badgeStyle.push('bg-yellow-500 !text-yellow-100');
if (href) {
badgeStyle.push('hover:bg-yellow-400');
}
break;
case 'success':
badgeStyle.push('bg-green-500 text-green-100');
badgeStyle.push('bg-green-500 !text-green-100');
if (href) {
badgeStyle.push('hover:bg-green-400');
}
break;
default:
badgeStyle.push('bg-indigo-500 text-indigo-100');
badgeStyle.push('bg-indigo-500 !text-indigo-100');
if (href) {
badgeStyle.push('hover:bg-indigo-400');
}
}
if (className) {
badgeStyle.push(className);
}
return <span className={badgeStyle.join(' ')}>{children}</span>;
if (href?.includes('://')) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={badgeStyle.join(' ')}
>
{children}
</a>
);
} else if (href) {
return (
<Link href={href}>
<a className={badgeStyle.join(' ')}>{children}</a>
</Link>
);
} else {
return <span className={badgeStyle.join(' ')}>{children}</span>;
}
};
export default Badge;

View File

@@ -32,7 +32,7 @@ const DropdownItem: React.FC<DropdownItemProps> = ({
}
return (
<a
className={`flex items-center px-4 py-2 text-sm leading-5 cursor-pointer focus:outline-none ${styleClass}`}
className={`flex cursor-pointer items-center px-4 py-2 text-sm leading-5 focus:outline-none ${styleClass}`}
{...props}
>
{children}
@@ -84,7 +84,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
<span className="relative inline-flex h-full rounded-md shadow-sm">
<button
type="button"
className={`relative inline-flex h-full items-center px-4 py-2 text-sm leading-5 font-medium z-10 hover:z-20 focus:z-20 focus:outline-none transition ease-in-out duration-150 ${
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
styleClasses.mainButtonClasses
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
ref={buttonRef}
@@ -93,10 +93,10 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
{text}
</button>
{children && (
<span className="relative block -ml-px">
<span className="relative -ml-px block">
<button
type="button"
className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md z-10 hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
aria-label="Expand"
onClick={() => setIsOpen((state) => !state)}
>
@@ -111,7 +111,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="absolute right-0 z-40 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
<div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>

View File

@@ -34,7 +34,7 @@ const ConfirmButton: React.FC<ConfirmButtonProps> = ({
&nbsp;
<div
ref={ref}
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
@@ -44,7 +44,7 @@ const ConfirmButton: React.FC<ConfirmButtonProps> = ({
</div>
<div
ref={ref}
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>

View File

@@ -12,9 +12,9 @@ const Header: React.FC<HeaderProps> = ({
}) => {
return (
<div className="mt-8 md:flex md:items-center md:justify-between">
<div className={`flex-1 min-w-0 mx-${extraMargin}`}>
<h2 className="mb-4 text-2xl font-bold leading-7 text-gray-100 truncate sm:text-4xl sm:leading-9 sm:overflow-visible md:mb-0">
<span className="text-transparent bg-clip-text bg-gradient-to-br from-indigo-400 to-purple-400">
<div className={`min-w-0 flex-1 mx-${extraMargin}`}>
<h2 className="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0">
<span className="bg-gradient-to-br from-indigo-400 to-purple-400 bg-clip-text text-transparent">
{children}
</span>
</h2>

View File

@@ -59,13 +59,13 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
{backgroundImages.map((imageUrl, i) => (
<div
key={`banner-image-${i}`}
className={`absolute absolute-top-shift inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
className={`absolute-top-shift absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
i === activeIndex ? 'opacity-100' : 'opacity-0'
}`}
{...props}
>
<CachedImage
className="absolute inset-0 w-full h-full"
className="absolute inset-0 h-full w-full"
alt=""
src={imageUrl}
layout="fill"

View File

@@ -11,7 +11,7 @@ const ListItem: React.FC<ListItemProps> = ({ title, className, children }) => {
<div>
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="block text-sm font-bold text-gray-400">{title}</dt>
<dd className="flex text-sm text-white sm:mt-0 sm:col-span-2">
<dd className="flex text-sm text-white sm:col-span-2 sm:mt-0">
<span className={`flex-grow ${className}`}>{children}</span>
</dd>
</div>
@@ -31,7 +31,7 @@ const List: React.FC<ListProps> = ({ title, subTitle, children }) => {
<h3 className="heading">{title}</h3>
{subTitle && <p className="description">{subTitle}</p>}
</div>
<div className="border-t border-gray-800 section">
<div className="section border-t border-gray-800">
<dl className="divide-y divide-gray-800">{children}</dl>
</div>
</>

View File

@@ -30,7 +30,7 @@ const ListView: React.FC<ListViewProps> = ({
return (
<>
{isEmpty && (
<div className="w-full mt-64 text-2xl text-center text-gray-400">
<div className="mt-64 w-full text-center text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</div>
)}

View File

@@ -2,9 +2,9 @@ import React from 'react';
export const SmallLoadingSpinner: React.FC = () => {
return (
<div className="inset-0 flex items-center justify-center w-full h-full text-gray-200">
<div className="inset-0 flex h-full w-full items-center justify-center text-gray-200">
<svg
className="w-10 h-10"
className="h-10 w-10"
viewBox="0 0 38 38"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
@@ -31,9 +31,9 @@ export const SmallLoadingSpinner: React.FC = () => {
const LoadingSpinner: React.FC = () => {
return (
<div className="inset-0 flex items-center justify-center h-64 text-gray-200">
<div className="inset-0 flex h-64 items-center justify-center text-gray-200">
<svg
className="w-16 h-16"
className="h-16 w-16"
viewBox="0 0 38 38"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"

View File

@@ -69,7 +69,7 @@ const Modal: React.FC<ModalProps> = ({
return ReactDOM.createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex items-center justify-center w-full h-full bg-gray-800 bg-opacity-70"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
@@ -101,7 +101,7 @@ const Modal: React.FC<ModalProps> = ({
show={!loading}
>
<div
className="relative inline-block w-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl ring-1 ring-gray-500 sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
className="relative inline-block w-full transform overflow-auto bg-gray-700 px-4 pt-5 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-500 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
@@ -111,7 +111,7 @@ const Modal: React.FC<ModalProps> = ({
}}
>
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64">
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage
alt=""
src={backdrop}
@@ -128,16 +128,16 @@ const Modal: React.FC<ModalProps> = ({
/>
</div>
)}
<div className="relative sm:flex sm:items-center">
<div className="relative overflow-x-hidden sm:flex sm:items-center">
{iconSvg && <div className="modal-icon">{iconSvg}</div>}
<div
className={`mt-3 text-center sm:mt-0 sm:text-left ${
className={`mt-3 truncate text-center text-white sm:mt-0 sm:text-left ${
iconSvg ? 'sm:ml-4' : 'sm:mb-4'
}`}
>
{title && (
<span
className="text-lg font-bold leading-6 text-white"
className="truncate text-lg font-bold leading-6"
id="modal-headline"
>
{title}
@@ -151,7 +151,7 @@ const Modal: React.FC<ModalProps> = ({
</div>
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
<div className="relative flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
<div className="relative mt-5 flex flex-row-reverse justify-center sm:mt-4 sm:justify-start">
{typeof onOk === 'function' && (
<Button
buttonType={okButtonType}

View File

@@ -120,7 +120,7 @@ const SettingsTabs: React.FC<{
</div>
{tabType === 'button' ? (
<div className="hidden sm:block">
<nav className="flex flex-wrap -mx-2 -my-1" aria-label="Tabs">
<nav className="-mx-2 -my-1 flex flex-wrap" aria-label="Tabs">
{settingsRoutes.map((route, index) => (
<SettingsLink
tabType={tabType}
@@ -136,7 +136,7 @@ const SettingsTabs: React.FC<{
</nav>
</div>
) : (
<div className="hidden overflow-x-scroll border-b border-gray-600 sm:block hide-scrollbar">
<div className="hide-scrollbar hidden overflow-x-scroll border-b border-gray-600 sm:block">
<nav className="flex">
{settingsRoutes
.filter(

View File

@@ -7,7 +7,7 @@ import Transition from '../../Transition';
interface SlideOverProps {
show?: boolean;
title: string;
title: React.ReactNode;
subText?: string;
onClose: () => void;
}
@@ -44,7 +44,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-70 bg-gray-800`}
className={`fixed inset-0 z-50 overflow-hidden bg-gray-800 bg-opacity-70`}
onClick={() => onClose()}
onKeyDown={(e) => {
if (e.key === 'Escape') {
@@ -70,19 +70,19 @@ const SlideOver: React.FC<SlideOverProps> = ({
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
<header className="px-4 space-y-1 bg-indigo-600 slideover">
<div className="flex h-full flex-col overflow-y-scroll bg-gray-700 shadow-xl">
<header className="slideover space-y-1 bg-indigo-600 px-4">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg font-bold leading-7 text-white">
{title}
</h2>
<div className="flex items-center h-7">
<div className="flex h-7 items-center">
<button
aria-label="Close panel"
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<XIcon className="w-6 h-6" />
<XIcon className="h-6 w-6" />
</button>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { withProperties } from '../../../utils/typeHelpers';
const TBody: React.FC = ({ children }) => {
return (
<tbody className="bg-gray-800 divide-y divide-gray-700">{children}</tbody>
<tbody className="divide-y divide-gray-700 bg-gray-800">{children}</tbody>
);
};

View File

@@ -13,10 +13,10 @@ const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 w-56 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
className={`relative flex h-32 w-56 transform-gpu cursor-pointer items-center justify-center p-8 shadow ring-1 transition duration-300 ease-in-out sm:h-36 sm:w-72 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500'
: 'bg-gray-800 scale-100 ring-gray-700'
? 'scale-105 bg-gray-700 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700'
} rounded-xl`}
onMouseEnter={() => {
setHovered(true);
@@ -33,10 +33,10 @@ const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
<img
src={image}
alt={name}
className="relative z-40 max-w-full max-h-full"
className="relative z-40 max-h-full max-w-full"
/>
<div
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t z-0 ${
className={`absolute bottom-0 left-0 right-0 z-0 h-12 rounded-b-xl bg-gradient-to-t ${
isHovered ? 'from-gray-800' : 'from-gray-900'
}`}
/>

View File

@@ -47,7 +47,7 @@ const DiscoverTvNetwork: React.FC = () => {
<div className="mt-1 mb-5">
<Header>
{firstResultData?.network.logoPath ? (
<div className="flex justify-center mb-6">
<div className="mb-6 flex justify-center">
<img
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
alt={firstResultData.network.name}

View File

@@ -47,7 +47,7 @@ const DiscoverMovieStudio: React.FC = () => {
<div className="mt-1 mb-5">
<Header>
{firstResultData?.studio.logoPath ? (
<div className="flex justify-center mb-6">
<div className="mb-6 flex justify-center">
<img
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
alt={firstResultData.studio.name}

View File

@@ -38,12 +38,24 @@ const networks: Network[] = [
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png',
url: '/discover/tv/network/2552',
},
{
name: 'Hulu',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pqUTCleNUiTLAVlelGxUgWn1ELh.png',
url: '/discover/tv/network/453',
},
{
name: 'HBO',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
url: '/discover/tv/network/49',
},
{
name: 'Discovery+',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1D1bS3Dyw4ScYnFWTlBOvJXC3nb.png',
url: '/discover/tv/network/4353',
},
{
name: 'ABC',
image:

View File

@@ -20,12 +20,12 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
return (
<div className="p-4">
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
<div className="mb-2 w-56 truncate text-sm sm:w-80 md:w-full">
{downloadItem.title}
</div>
<div className="relative h-6 min-w-0 mb-2 overflow-hidden bg-gray-700 rounded-full">
<div className="relative mb-2 h-6 min-w-0 overflow-hidden rounded-full bg-gray-700">
<div
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
className="h-8 bg-indigo-600 transition-all duration-200 ease-in-out"
style={{
width: `${
downloadItem.size
@@ -38,7 +38,7 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
}%`,
}}
/>
<div className="absolute inset-0 flex items-center justify-center w-full h-6 text-xs">
<div className="absolute inset-0 flex h-6 w-full items-center justify-center text-xs">
<span>
{downloadItem.size
? Math.round(

View File

@@ -6,6 +6,7 @@ import JellyfinLogo from '../../assets/services/jellyfin.svg';
import PlexLogo from '../../assets/services/plex.svg';
import RTLogo from '../../assets/services/rt.svg';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TraktLogo from '../../assets/services/trakt.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
@@ -31,15 +32,11 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
const { locale } = useLocale();
return (
<div className="flex items-center justify-end">
<div className="flex w-full items-center justify-center space-x-5">
{mediaUrl && (
<a
href={mediaUrl}
className={`${
settings.currentSettings.mediaServerType === MediaServerType.PLEX
? 'w-8'
: 'w-14'
} mx-2 transition duration-300 opacity-50 hover:opacity-100`}
className="w-12 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -53,7 +50,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}?language=${locale}`}
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
className="w-8 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -63,7 +60,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{tvdbId && mediaType === MediaType.TV && (
<a
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
className="transition duration-300 opacity-50 w-9 hover:opacity-100"
className="w-9 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -73,7 +70,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{imdbId && (
<a
href={`https://www.imdb.com/title/${imdbId}`}
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
className="w-8 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@@ -83,13 +80,25 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{rtUrl && (
<a
href={`${rtUrl}`}
className="transition duration-300 opacity-50 w-14 hover:opacity-100"
className="w-14 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
<RTLogo />
</a>
)}
{tmdbId && (
<a
href={`https://trakt.tv/search/tmdb/${tmdbId}?id_type=${
mediaType === 'movie' ? 'movie' : 'show'
}`}
className="w-8 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
<TraktLogo />
</a>
)}
</div>
);
};

View File

@@ -21,13 +21,13 @@ const GenreCard: React.FC<GenreCardProps> = ({
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 sm:h-36 ${
className={`relative flex h-32 items-center justify-center sm:h-36 ${
canExpand ? 'w-full' : 'w-56 sm:w-72'
} p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
} transform-gpu cursor-pointer p-8 shadow ring-1 transition duration-300 ease-in-out ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100'
: 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80'
} rounded-xl bg-cover bg-center overflow-hidden`}
? 'scale-105 bg-gray-700 bg-opacity-100 ring-gray-500'
: 'scale-100 bg-gray-800 bg-opacity-80 ring-gray-700'
} overflow-hidden rounded-xl bg-cover bg-center`}
onMouseEnter={() => {
setHovered(true);
}}
@@ -42,11 +42,11 @@ const GenreCard: React.FC<GenreCardProps> = ({
>
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
<div
className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${
className={`absolute inset-0 z-10 h-full w-full bg-gray-800 transition duration-300 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
}`}
/>
<div className="relative z-20 w-full text-2xl font-bold text-center text-white truncate whitespace-normal sm:text-3xl">
<div className="relative z-20 w-full truncate whitespace-normal text-center text-2xl font-bold text-white sm:text-3xl">
{name}
</div>
</a>
@@ -57,7 +57,7 @@ const GenreCard: React.FC<GenreCardProps> = ({
const GenreCardPlaceholder: React.FC = () => {
return (
<div
className={`relative h-32 w-56 sm:h-40 sm:w-72 animate-pulse rounded-xl bg-gray-700`}
className={`relative h-32 w-56 animate-pulse rounded-xl bg-gray-700 sm:h-40 sm:w-72`}
></div>
);
};

View File

@@ -0,0 +1,79 @@
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 { useUser } from '../../hooks/useUser';
import Button from '../Common/Button';
import { issueOptions } from '../IssueModal/constants';
interface IssueBlockProps {
issue: Issue;
}
const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
const { user } = useUser();
const intl = useIntl();
const issueOption = issueOptions.find(
(opt) => opt.issueType === issue.issueType
);
if (!issueOption) {
return null;
}
return (
<div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="flex flex-nowrap">
<ExclamationIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(issueOption.name)}
</span>
</div>
<div className="white mb-1 flex flex-nowrap">
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
<Link
href={
issue.createdBy.id === user?.id
? '/profile'
: `/users/${issue.createdBy.id}`
}
>
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{issue.createdBy.displayName}
</a>
</Link>
</span>
</div>
<div className="white mb-1 flex flex-nowrap">
<CalendarIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
{intl.formatDate(issue.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">
<Link href={`/issues/${issue.id}`} passHref>
<Button buttonType="primary" as="a">
<EyeIcon />
</Button>
</Link>
</div>
</div>
</div>
);
};
export default IssueBlock;

View File

@@ -0,0 +1,269 @@
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 Link from 'next/link';
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 {relativeTime} by {username}',
postedbyedited: 'Posted {relativeTime} by {username} (Edited)',
delete: 'Delete Comment',
areyousuredelete: 'Are you sure you want to delete this comment?',
validationComment: 'You must enter 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 { 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();
}
}
};
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>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<a>
<img
src={comment.user.avatar}
alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
<div className="relative flex-1">
<div className="w-full rounded-md shadow ring-1 ring-gray-500">
{(isActiveUser || hasPermission(Permission.MANAGE_ISSUES)) && (
<Menu
as="div"
className="absolute top-2 right-1 z-40 inline-block text-left"
>
{({ open }) => (
<>
<div>
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open options</span>
<DotsVerticalIcon
className="h-5 w-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 mt-2 w-56 origin-top-right rounded-md bg-gray-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
{isActiveUser && (
<Menu.Item>
{({ active }) => (
<button
onClick={() => setIsEditing(true)}
className={`block w-full px-4 py-2 text-left 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 px-4 py-2 text-left 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 top-3 z-10 h-3 w-3 rotate-45 transform bg-gray-800 shadow ring-1 ring-gray-500 ${
isReversed ? '-left-1' : '-right-1'
}`}
/>
<div className="relative z-20 w-full rounded-md bg-gray-800 py-4 pl-4 pr-8">
{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="mt-4 flex items-center justify-end 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="prose w-full max-w-full">
<ReactMarkdown skipHtml allowedElements={['p', 'em', 'strong']}>
{comment.message}
</ReactMarkdown>
</div>
)}
</div>
</div>
<div
className={`flex items-center justify-between pt-2 text-xs ${
isReversed ? 'flex-row-reverse' : 'flex-row'
}`}
>
<span>
{intl.formatMessage(
comment.createdAt !== comment.updatedAt
? messages.postedbyedited
: messages.postedby,
{
username: (
<Link
href={
isActiveUser ? '/profile' : `/users/${comment.user.id}`
}
>
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{comment.user.displayName}
</a>
</Link>
),
relativeTime: (
<FormattedRelativeTime
value={Math.floor(
(new Date(comment.createdAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
}
)}
</span>
</div>
</div>
</div>
);
};
export default IssueComment;

View File

@@ -0,0 +1,157 @@
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 globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
const messages = defineMessages({
description: 'Description',
edit: 'Edit Description',
deleteissue: 'Delete Issue',
});
interface IssueDescriptionProps {
description: string;
belongsToUser: boolean;
commentCount: number;
onEdit: (newDescription: string) => void;
onDelete: () => void;
}
const IssueDescription: React.FC<IssueDescriptionProps> = ({
description,
belongsToUser,
commentCount,
onEdit,
onDelete,
}) => {
const intl = useIntl();
const { 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) || belongsToUser) && (
<Menu as="div" className="relative inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open options</span>
<DotsVerticalIcon className="h-5 w-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 mt-2 w-56 origin-top-right rounded-md bg-gray-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="py-1">
{belongsToUser && (
<Menu.Item>
{({ active }) => (
<button
onClick={() => setIsEditing(true)}
className={`block w-full px-4 py-2 text-left text-sm ${
active
? 'bg-gray-600 text-white'
: 'text-gray-100'
}`}
>
{intl.formatMessage(messages.edit)}
</button>
)}
</Menu.Item>
)}
{(hasPermission(Permission.MANAGE_ISSUES) ||
!commentCount) && (
<Menu.Item>
{({ active }) => (
<button
onClick={() => onDelete()}
className={`block w-full px-4 py-2 text-left 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="mt-2 flex justify-end">
<Button
buttonType="default"
className="mr-2"
type="button"
onClick={() => setIsEditing(false)}
>
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
<Button buttonType="primary">
<span>{intl.formatMessage(globalMessages.save)}</span>
</Button>
</div>
</Form>
);
}}
</Formik>
) : (
<div className="prose mt-4">
<ReactMarkdown
allowedElements={['p', 'img', 'strong', 'em']}
skipHtml
>
{description}
</ReactMarkdown>
</div>
)}
</div>
);
};
export default IssueDescription;

View File

@@ -0,0 +1,699 @@
import {
ChatIcon,
CheckCircleIcon,
ExclamationIcon,
PlayIcon,
ServerIcon,
} from '@heroicons/react/outline';
import { RefreshIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaType } from '../../../server/constants/media';
import type Issue from '../../../server/entity/Issue';
import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import PageTitle from '../Common/PageTitle';
import { issueOptions } from '../IssueModal/constants';
import Transition from '../Transition';
import IssueComment from './IssueComment';
import IssueDescription from './IssueDescription';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
openedby: '#{issueId} opened {relativeTime} by {username}',
closeissue: 'Close Issue',
closeissueandcomment: 'Close with Comment',
leavecomment: 'Comment',
comments: 'Comments',
reopenissue: 'Reopen Issue',
reopenissueandcomment: 'Reopen with Comment',
issuepagetitle: 'Issue',
playonplex: 'Play on {mediaServerName}',
play4konplex: 'Play in 4K on {mediaServerName}',
openinarr: 'Open in {arr}',
openin4karr: 'Open in 4K {arr}',
toasteditdescriptionsuccess: 'Issue description edited successfully!',
toasteditdescriptionfailed:
'Something went wrong while editing the issue description.',
toaststatusupdated: 'Issue status updated successfully!',
toaststatusupdatefailed:
'Something went wrong while updating the issue status.',
issuetype: 'Type',
lastupdated: 'Last Updated',
problemseason: 'Affected Season',
allseasons: 'All Seasons',
season: 'Season {seasonNumber}',
problemepisode: 'Affected Episode',
allepisodes: 'All Episodes',
episode: 'Episode {episodeNumber}',
deleteissue: 'Delete Issue',
deleteissueconfirm: 'Are you sure you want to delete this issue?',
toastissuedeleted: 'Issue deleted successfully!',
toastissuedeletefailed: 'Something went wrong while deleting the issue.',
nocomments: 'No comments.',
unknownissuetype: 'Unknown',
commentplaceholder: 'Add a comment…',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const IssueDetails: React.FC = () => {
const { addToast } = useToasts();
const router = useRouter();
const intl = useIntl();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { user: currentUser, hasPermission } = useUser();
const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>(
`/api/v1/issue/${router.query.issueId}`
);
const { data, error } = useSWR<MovieDetails | TvDetails>(
issueData?.media.tmdbId
? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}`
: null
);
const CommentSchema = Yup.object().shape({
message: Yup.string().required(),
});
const issueOption = issueOptions.find(
(opt) => opt.issueType === issueData?.issueType
);
const settings = useSettings();
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data || !issueData) {
return <Error statusCode={404} />;
}
const belongsToUser = issueData.createdBy.id === currentUser?.id;
const [firstComment, ...otherComments] = issueData.comments;
const editFirstComment = async (newMessage: string) => {
try {
await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
message: newMessage,
});
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
appearance: 'success',
autoDismiss: true,
});
revalidateIssue();
} catch (e) {
addToast(intl.formatMessage(messages.toasteditdescriptionfailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
try {
await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
addToast(intl.formatMessage(messages.toaststatusupdated), {
appearance: 'success',
autoDismiss: true,
});
revalidateIssue();
} catch (e) {
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const deleteIssue = async () => {
try {
await axios.delete(`/api/v1/issue/${issueData.id}`);
addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success',
autoDismiss: true,
});
router.push('/issues');
} catch (e) {
addToast(intl.formatMessage(messages.toastissuedeletefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const title = isMovie(data) ? data.title : data.name;
const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate;
return (
<div
className="media-page"
style={{
height: 493,
}}
>
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
>
<Modal
title={intl.formatMessage(messages.deleteissue)}
onCancel={() => setShowDeleteModal(false)}
onOk={() => deleteIssue()}
okText={intl.formatMessage(messages.deleteissue)}
okButtonType="danger"
iconSvg={<ExclamationIcon />}
>
{intl.formatMessage(messages.deleteissueconfirm)}
</Modal>
</Transition>
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<div className="media-header">
<div className="media-poster">
<CachedImage
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
{issueData.status === IssueStatus.OPEN && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.open)}
</Badge>
)}
{issueData.status === IssueStatus.RESOLVED && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.resolved)}
</Badge>
)}
</div>
<h1>
<Link
href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
}/${data.id}`}
>
<a className="hover:underline">{title}</a>
</Link>{' '}
{releaseYear && (
<span className="media-year">({releaseYear.slice(0, 4)})</span>
)}
</h1>
<span className="media-attributes">
{intl.formatMessage(messages.openedby, {
issueId: issueData.id,
username: (
<Link
href={
belongsToUser
? '/profile'
: `/users/${issueData.createdBy.id}`
}
>
<a className="group ml-1 inline-flex h-full items-center xl:ml-1.5">
<img
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar}
alt=""
/>
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
{issueData.createdBy.displayName}
</span>
</a>
</Link>
),
relativeTime: (
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.createdAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
})}
</span>
</div>
</div>
<div className="relative z-10 mt-6 flex text-gray-300">
<div className="flex-1 lg:pr-4">
<IssueDescription
description={firstComment.message}
belongsToUser={belongsToUser}
commentCount={otherComments.length}
onEdit={(newMessage) => {
editFirstComment(newMessage);
}}
onDelete={() => setShowDeleteModal(true)}
/>
<div className="mt-8 lg:hidden">
<div className="media-facts">
<div className="media-fact">
<span>{intl.formatMessage(messages.issuetype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
{issueData.media.mediaType === MediaType.TV && (
<>
<div className="media-fact">
<span>{intl.formatMessage(messages.problemseason)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemSeason > 0
? messages.season
: messages.allseasons,
{ seasonNumber: issueData.problemSeason }
)}
</span>
</div>
{issueData.problemSeason > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.problemepisode)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemEpisode > 0
? messages.episode
: messages.allepisodes,
{ episodeNumber: issueData.problemEpisode }
)}
</span>
</div>
)}
</>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.lastupdated)}</span>
<span className="media-fact-value">
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{intl.formatMessage(messages.playonplex, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{intl.formatMessage(messages.play4konplex, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl4k &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
</div>
</div>
<div className="mt-6">
<div className="font-semibold text-gray-100 lg:text-xl">
{intl.formatMessage(messages.comments)}
</div>
{otherComments.map((comment) => (
<IssueComment
comment={comment}
key={`issue-comment-${comment.id}`}
isReversed={issueData.createdBy.id === comment.user.id}
isActiveUser={comment.user.id === currentUser?.id}
onUpdate={() => revalidateIssue()}
/>
))}
{otherComments.length === 0 && (
<div className="mt-4 mb-10 text-gray-400">
<span>{intl.formatMessage(messages.nocomments)}</span>
</div>
)}
{(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && (
<Formik
initialValues={{
message: '',
}}
validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => {
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
message: values.message,
});
revalidateIssue();
resetForm();
}}
>
{({ isValid, isSubmitting, values, handleSubmit }) => {
return (
<Form>
<div className="my-6">
<Field
id="message"
name="message"
as="textarea"
placeholder={intl.formatMessage(
messages.commentplaceholder
)}
className="h-20"
/>
<div className="mt-4 flex items-center justify-end 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:w-80 lg:pl-4">
<div className="media-facts">
<div className="media-fact">
<span>{intl.formatMessage(messages.issuetype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
{issueData.media.mediaType === MediaType.TV && (
<>
<div className="media-fact">
<span>{intl.formatMessage(messages.problemseason)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemSeason > 0
? messages.season
: messages.allseasons,
{ seasonNumber: issueData.problemSeason }
)}
</span>
</div>
{issueData.problemSeason > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.problemepisode)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemEpisode > 0
? messages.episode
: messages.allepisodes,
{ episodeNumber: issueData.problemEpisode }
)}
</span>
</div>
)}
</>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.lastupdated)}</span>
<span className="media-fact-value">
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{intl.formatMessage(messages.playonplex, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{intl.formatMessage(messages.play4konplex, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
</div>
</div>
</div>
</div>
);
};
export default IssueDetails;

View File

@@ -0,0 +1,275 @@
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}',
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
episodes: '{episodeCount, plural, one {Episode} other {Episodes}}',
problemepisode: 'Affected Episode',
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="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28"
ref={ref}
/>
);
}
if (!title) {
return <div>uh oh</div>;
}
const issueOption = issueOptions.find(
(opt) => opt.issueType === issue?.issueType
);
const problemSeasonEpisodeLine: React.ReactNode[] = [];
if (!isMovie(title) && issue) {
problemSeasonEpisodeLine.push(
<>
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount: issue.problemSeason ? 1 : 0,
})}
</span>
<span className="mr-4 uppercase">
<Badge>
{issue.problemSeason > 0
? issue.problemSeason
: intl.formatMessage(globalMessages.all)}
</Badge>
</span>
</>
);
if (issue.problemSeason > 0) {
problemSeasonEpisodeLine.push(
<>
<span className="card-field-name">
{intl.formatMessage(messages.episodes, {
episodeCount: issue.problemEpisode ? 1 : 0,
})}
</span>
<span className="uppercase">
<Badge>
{issue.problemEpisode > 0
? issue.problemEpisode
: intl.formatMessage(globalMessages.all)}
</Badge>
</span>
</>
);
}
}
return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center 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 w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<Link
href={
issue.media.mediaType === MediaType.MOVIE
? `/movie/${issue.media.tmdbId}`
: `/tv/${issue.media.tmdbId}`
}
>
<a className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 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 overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs text-white sm:pt-1">
{(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="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{isMovie(title) ? title.title : title.name}
</a>
</Link>
{problemSeasonEpisodeLine.length > 0 && (
<div className="card-field">
{problemSeasonEpisodeLine.map((t, k) => (
<span key={k}>{t}</span>
))}
</div>
)}
</div>
</div>
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 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="warning" href={`/issues/${issue.id}`}>
{intl.formatMessage(globalMessages.open)}
</Badge>
) : (
<Badge badgeType="success" href={`/issues/${issue.id}`}>
{intl.formatMessage(globalMessages.resolved)}
</Badge>
)}
</div>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.issuetype)}
</span>
<span className="flex truncate text-sm text-gray-300">
{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 truncate text-sm text-gray-300">
{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="group flex items-center truncate">
<img
src={issue.createdBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{issue.createdBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.opened)}
</span>
<span className="flex truncate text-sm text-gray-300">
<FormattedRelativeTime
value={Math.floor(
(new Date(issue.createdAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</>
)}
</div>
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<span className="w-full">
<Link href={`/issues/${issue.id}`} passHref>
<Button as="a" className="w-full" buttonType="primary">
<EyeIcon />
<span>{intl.formatMessage(messages.viewissue)}</span>
</Button>
</Link>
</span>
</div>
</div>
);
};
export default IssueItem;

View File

@@ -0,0 +1,256 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces';
import Button from '../../components/Common/Button';
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import globalMessages from '../../i18n/globalMessages';
import Header from '../Common/Header';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import IssueItem from './IssueItem';
const messages = defineMessages({
issues: 'Issues',
sortAdded: 'Most Recent',
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="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{intl.formatMessage(messages.issues)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-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="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<SortDescendingIcon className="h-6 w-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="py-2" key={`issue-item-${issue.id}`}>
<IssueItem issue={issue} />
</div>
);
})}
{data.results.length === 0 && (
<div className="flex w-full flex-col items-center justify-center 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="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
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="-mt-3 items-center truncate text-sm 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="short inline"
>
<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 flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button
disabled={!hasPrevPage}
onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</>
);
};
export default IssueList;

View File

@@ -0,0 +1,329 @@
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 { MediaStatus } from '../../../../server/constants/media';
import type Issue from '../../../../server/entity/Issue';
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 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:
'Please provide a detailed explanation of the issue you encountered.',
extras: 'Extras',
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 settings = useSettings();
const { hasPermission } = useUser();
const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails | TvDetails>(
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null
);
if (!tmdbId) {
return null;
}
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
.filter(
(season) =>
season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
(settings.currentSettings.series4kEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) &&
(season.status4k === MediaStatus.AVAILABLE ||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE))
)
.map((season) => season.seasonNumber);
const CreateIssueModalSchema = Yup.object().shape({
message: Yup.string().required(
intl.formatMessage(messages.validationMessageRequired)
),
});
return (
<Formik
initialValues={{
selectedIssue: issueOptions[0],
message: '',
problemSeason: availableSeasons.length === 1 ? availableSeasons[0] : 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-area">
<div className="form-input-field">
<Field
as="select"
id="problemSeason"
name="problemSeason"
disabled={availableSeasons.length === 1}
>
{availableSeasons.length > 1 && (
<option value={0}>
{intl.formatMessage(messages.allseasons)}
</option>
)}
{availableSeasons.map((season) => (
<option
value={season}
key={`problem-season-${season}`}
>
{season === 0
? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.season, {
seasonNumber: season,
})}
</option>
))}
</Field>
</div>
</div>
</div>
{values.problemSeason > 0 && (
<div className="form-row mb-2">
<label htmlFor="problemEpisode" className="text-label">
{intl.formatMessage(messages.problemepisode)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<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 rounded-md bg-gray-800 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
? 'z-10 border-indigo-500 bg-indigo-600'
: 'border-gray-500',
'relative flex cursor-pointer border p-4 focus:outline-none'
)
}
>
{({ active, checked }) => (
<>
<span
className={`${
checked
? 'border-transparent bg-indigo-800'
: 'border-gray-300 bg-white'
} ${
active ? 'ring-2 ring-indigo-300 ring-offset-2' : ''
} mt-0.5 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full border`}
aria-hidden="true"
>
<span className="h-1.5 w-1.5 rounded-full bg-white" />
</span>
<div className="ml-3 flex flex-col">
<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="mt-4 flex-col space-y-2">
<label htmlFor="message">
{intl.formatMessage(messages.whatswrong)}
<span className="label-required">*</span>
</label>
<Field
as="textarea"
name="message"
id="message"
className="h-28"
placeholder={intl.formatMessage(messages.providedetail)}
/>
{errors.message && touched.message && (
<div className="error">{errors.message}</div>
)}
</div>
</Modal>
);
}}
</Formik>
);
};
export default CreateIssueModal;

View File

@@ -0,0 +1,34 @@
import { defineMessages, MessageDescriptor } from 'react-intl';
import { IssueType } from '../../../server/constants/issue';
const messages = defineMessages({
issueAudio: 'Audio',
issueVideo: 'Video',
issueSubtitles: 'Subtitle',
issueOther: 'Other',
});
interface IssueOption {
name: MessageDescriptor;
issueType: IssueType;
mediaType?: 'movie' | 'tv';
}
export const issueOptions: IssueOption[] = [
{
name: messages.issueVideo,
issueType: IssueType.VIDEO,
},
{
name: messages.issueAudio,
issueType: IssueType.AUDIO,
},
{
name: messages.issueSubtitles,
issueType: IssueType.SUBTITLES,
},
{
name: messages.issueOther,
issueType: IssueType.OTHER,
},
];

View File

@@ -0,0 +1,36 @@
import React from 'react';
import Transition from '../Transition';
import CreateIssueModal from './CreateIssueModal';
interface IssueModalProps {
show?: boolean;
onCancel: () => void;
mediaType: 'movie' | 'tv';
tmdbId: number;
issueId?: never;
}
const IssueModal: React.FC<IssueModalProps> = ({
show,
mediaType,
onCancel,
tmdbId,
}) => (
<Transition
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<CreateIssueModal
mediaType={mediaType}
onCancel={onCancel}
tmdbId={tmdbId}
/>
</Transition>
);
export default IssueModal;

View File

@@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { sortBy } from 'lodash';
import dynamic from 'next/dynamic';
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import Select, { CSSObjectWithLabel } from 'react-select';
import useSWR from 'swr';
import { Language } from '../../../server/lib/settings';
import globalMessages from '../../i18n/globalMessages';
@@ -13,8 +11,6 @@ const messages = defineMessages({
languageServerDefault: 'Default ({language})',
});
const Select = dynamic(() => import('react-select'), { ssr: false });
type OptionType = {
value: string;
label: string;
@@ -22,11 +18,11 @@ type OptionType = {
};
const selectStyles = {
multiValueLabel: (base: any, state: { data: { isFixed?: boolean } }) => {
return state.data.isFixed ? { ...base, paddingRight: 6 } : base;
multiValueLabel: (base: CSSObjectWithLabel, props: { data: OptionType }) => {
return props.data?.isFixed ? { ...base, paddingRight: 6 } : base;
},
multiValueRemove: (base: any, state: { data: { isFixed?: boolean } }) => {
return state.data.isFixed ? { ...base, display: 'none' } : base;
multiValueRemove: (base: CSSObjectWithLabel, props: { data: OptionType }) => {
return props.data?.isFixed ? { ...base, display: 'none' } : base;
},
};
@@ -95,7 +91,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
});
return (
<Select
<Select<OptionType, true>
options={options}
isMulti
className="react-select-container"
@@ -125,36 +121,30 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
}),
isFixed: true,
}
: value?.split('|').map((code) => {
const matchedLanguage = sortedLanguages?.find(
(lang) => lang.iso_639_1 === code
);
: (value
?.split('|')
.map((code) => {
const matchedLanguage = sortedLanguages?.find(
(lang) => lang.iso_639_1 === code
);
if (!matchedLanguage) {
return undefined;
}
if (!matchedLanguage) {
return undefined;
}
return {
label: matchedLanguage.name,
value: matchedLanguage.iso_639_1,
};
}) ?? undefined
return {
label: matchedLanguage.name,
value: matchedLanguage.iso_639_1,
};
})
.filter((option) => option !== undefined) as OptionType[])
}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null,
options
) => {
if (!Array.isArray(value)) {
return;
}
onChange={(value, options) => {
if (
(options &&
options.action === 'select-option' &&
options.option?.value === 'server') ||
value?.every(
(v: { value: string; label: string }) => v.value === 'server'
)
value.every((v) => v.value === 'server')
) {
return setFieldValue('originalLanguage', '');
}
@@ -163,9 +153,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
(options &&
options.action === 'select-option' &&
options.option?.value === 'all') ||
value?.every(
(v: { value: string; label: string }) => v.value === 'all'
)
value.every((v) => v.value === 'all')
) {
return setFieldValue('originalLanguage', isUserSettings ? 'all' : '');
}
@@ -173,7 +161,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
setFieldValue(
'originalLanguage',
value
?.map((lang) => lang.value)
.map((lang) => lang.value)
.filter((v) => v !== 'all')
.join('|')
);

View File

@@ -24,13 +24,13 @@ const LanguagePicker: React.FC = () => {
<div className="relative">
<div>
<button
className={`p-1 rounded-full sm:p-2 hover:bg-gray-600 hover:text-white focus:outline-none focus:bg-gray-600 focus:ring-1 focus:ring-gray-500 focus:text-white ${
className={`rounded-full p-1 hover:bg-gray-600 hover:text-white focus:bg-gray-600 focus:text-white focus:outline-none focus:ring-1 focus:ring-gray-500 sm:p-2 ${
isDropdownOpen ? 'bg-gray-600 text-white' : 'text-gray-400'
}`}
aria-label="Language Picker"
onClick={() => setDropdownOpen(true)}
>
<TranslateIcon className="w-6 h-6" />
<TranslateIcon className="h-6 w-6" />
</button>
</div>
<Transition
@@ -43,10 +43,10 @@ const LanguagePicker: React.FC = () => {
leaveTo="transform opacity-0 scale-95"
>
<div
className="absolute right-0 w-56 mt-2 origin-top-right rounded-md shadow-lg"
className="absolute right-0 mt-2 w-56 origin-top-right rounded-md shadow-lg"
ref={dropdownRef}
>
<div className="px-3 py-2 bg-gray-700 rounded-md ring-1 ring-black ring-opacity-5">
<div className="rounded-md bg-gray-700 px-3 py-2 ring-1 ring-black ring-opacity-5">
<div>
<label
htmlFor="language"

View File

@@ -4,10 +4,10 @@ import React from 'react';
const Notifications: React.FC = () => {
return (
<button
className="p-1 text-gray-400 rounded-full hover:bg-gray-500 hover:text-white focus:outline-none focus:ring focus:text-white"
className="rounded-full p-1 text-gray-400 hover:bg-gray-500 hover:text-white focus:text-white focus:outline-none focus:ring"
aria-label="Notifications"
>
<BellIcon className="w-6 h-6" />
<BellIcon className="h-6 w-6" />
</button>
);
};

View File

@@ -17,17 +17,17 @@ const SearchInput: React.FC = () => {
<label htmlFor="search_field" className="sr-only">
Search
</label>
<div className="relative flex items-center w-full text-white focus-within:text-gray-200">
<div className="absolute inset-y-0 flex items-center pointer-events-none left-4">
<SearchIcon className="w-5 h-5" />
<div className="relative flex w-full items-center text-white focus-within:text-gray-200">
<div className="pointer-events-none absolute inset-y-0 left-4 flex items-center">
<SearchIcon className="h-5 w-5" />
</div>
<input
id="search_field"
style={{ paddingRight: searchValue.length > 0 ? '1.75rem' : '' }}
className="block w-full py-2 pl-10 text-white placeholder-gray-300 bg-gray-900 border border-gray-600 rounded-full bg-opacity-80 focus:bg-opacity-100 focus:border-gray-500 hover:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
className="block w-full rounded-full border border-gray-600 bg-gray-900 bg-opacity-80 py-2 pl-10 text-white placeholder-gray-300 hover:border-gray-500 focus:border-gray-500 focus:bg-opacity-100 focus:placeholder-gray-400 focus:outline-none focus:ring-0 sm:text-base"
placeholder={intl.formatMessage(messages.searchPlaceholder)}
type="search"
inputMode="search"
autoComplete="off"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setIsOpen(true)}
@@ -36,13 +36,19 @@ const SearchInput: React.FC = () => {
setIsOpen(false);
}
}}
onKeyUp={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
{searchValue.length > 0 && (
<button
className="absolute inset-y-0 p-1 m-auto text-gray-400 transition border-none outline-none right-2 h-7 w-7 focus:outline-none focus:border-none hover:text-white"
className="absolute inset-y-0 right-2 m-auto h-7 w-7 border-none p-1 text-gray-400 outline-none transition hover:text-white focus:border-none focus:outline-none"
onClick={() => clear()}
>
<XCircleIcon className="w-5 h-5" />
<XCircleIcon className="h-5 w-5" />
</button>
)}
</div>

View File

@@ -1,6 +1,7 @@
import {
ClockIcon,
CogIcon,
ExclamationIcon,
SparklesIcon,
UsersIcon,
XIcon,
@@ -17,6 +18,7 @@ import VersionStatus from '../VersionStatus';
const messages = defineMessages({
dashboard: 'Discover',
requests: 'Requests',
issues: 'Issues',
users: 'Users',
settings: 'Settings',
});
@@ -33,32 +35,47 @@ interface SidebarLinkProps {
activeRegExp: RegExp;
as?: string;
requiredPermission?: Permission | Permission[];
permissionType?: 'and' | 'or';
}
const SidebarLinks: SidebarLinkProps[] = [
{
href: '/',
messagesKey: 'dashboard',
svgIcon: <SparklesIcon className="w-6 h-6 mr-3" />,
svgIcon: <SparklesIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/(discover\/?(movies|tv)?)?$/,
},
{
href: '/requests',
messagesKey: 'requests',
svgIcon: <ClockIcon className="w-6 h-6 mr-3" />,
svgIcon: <ClockIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/requests/,
},
{
href: '/issues',
messagesKey: 'issues',
svgIcon: (
<ExclamationIcon className="mr-3 h-6 w-6 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',
svgIcon: <UsersIcon className="w-6 h-6 mr-3" />,
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/users/,
requiredPermission: Permission.MANAGE_USERS,
},
{
href: '/settings',
messagesKey: 'settings',
svgIcon: <CogIcon className="w-6 h-6 mr-3" />,
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/settings/,
requiredPermission: Permission.MANAGE_SETTINGS,
},
@@ -97,31 +114,33 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
leaveTo="-translate-x-full"
>
<>
<div className="relative flex flex-col flex-1 w-full max-w-xs bg-gray-800 sidebar">
<div className="absolute top-0 right-0 p-1 sidebar-close-button -mr-14">
<div className="sidebar relative flex w-full max-w-xs flex-1 flex-col bg-gray-800">
<div className="sidebar-close-button absolute top-0 right-0 -mr-14 p-1">
<button
className="flex items-center justify-center w-12 h-12 rounded-full focus:outline-none focus:bg-gray-600"
className="flex h-12 w-12 items-center justify-center rounded-full focus:bg-gray-600 focus:outline-none"
aria-label="Close sidebar"
onClick={() => setClosed()}
>
<XIcon className="w-6 h-6 text-white" />
<XIcon className="h-6 w-6 text-white" />
</button>
</div>
<div
ref={navRef}
className="flex flex-col flex-1 h-0 pt-8 pb-8 overflow-y-auto sm:pb-4"
className="flex h-0 flex-1 flex-col overflow-y-auto pt-8 pb-8 sm:pb-4"
>
<div className="flex items-center flex-shrink-0 px-2">
<div className="flex flex-shrink-0 items-center px-2">
<span className="px-4 text-xl text-gray-50">
<a href="/">
<img src="/logo_full.svg" alt="Logo" />
</a>
</span>
</div>
<nav className="flex-1 px-4 mt-16 space-y-4">
<nav className="mt-16 flex-1 space-y-4 px-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
? hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
: true
).map((sidebarLink) => {
return (
@@ -139,7 +158,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
}}
role="button"
tabIndex={0}
className={`flex items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white focus:outline-none transition ease-in-out duration-150
className={`flex items-center rounded-md px-2 py-2 text-base font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(
sidebarLink.activeRegExp
@@ -165,7 +184,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
)}
</div>
</div>
<div className="flex-shrink-0 w-14">
<div className="w-14 flex-shrink-0">
{/* <!-- Force sidebar to shrink to fit close icon --> */}
</div>
</>
@@ -175,20 +194,22 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</div>
<div className="fixed top-0 bottom-0 left-0 z-30 hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64 sidebar">
<div className="flex flex-col flex-1 h-0">
<div className="flex flex-col flex-1 pt-8 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0">
<div className="sidebar flex w-64 flex-col">
<div className="flex h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4">
<div className="flex flex-shrink-0 items-center">
<span className="px-4 text-2xl text-gray-50">
<a href="/">
<img src="/logo_full.svg" alt="Logo" />
</a>
</span>
</div>
<nav className="flex-1 px-4 mt-16 space-y-4">
<nav className="mt-16 flex-1 space-y-4 px-4">
{SidebarLinks.filter((link) =>
link.requiredPermission
? hasPermission(link.requiredPermission)
? hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
: true
).map((sidebarLink) => {
return (
@@ -198,7 +219,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
as={sidebarLink.as}
>
<a
className={`flex group items-center px-2 py-2 text-lg leading-6 font-medium rounded-md text-white focus:outline-none transition ease-in-out duration-150
className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none
${
router.pathname.match(
sidebarLink.activeRegExp

View File

@@ -33,14 +33,14 @@ const UserDropdown: React.FC = () => {
<div className="relative ml-3">
<div>
<button
className="flex items-center max-w-xs text-sm rounded-full ring-1 ring-gray-700 focus:outline-none focus:ring-gray-500 hover:ring-gray-500"
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
id="user-menu"
aria-label="User menu"
aria-haspopup="true"
onClick={() => setDropdownOpen(true)}
>
<img
className="w-8 h-8 rounded-full sm:w-10 sm:h-10"
className="h-8 w-8 rounded-full sm:h-10 sm:w-10"
src={user?.avatar}
alt=""
/>
@@ -56,11 +56,11 @@ const UserDropdown: React.FC = () => {
leaveTo="transform opacity-0 scale-95"
>
<div
className="absolute right-0 w-48 mt-2 origin-top-right rounded-md shadow-lg"
className="absolute right-0 mt-2 w-48 origin-top-right rounded-md shadow-lg"
ref={dropdownRef}
>
<div
className="py-1 bg-gray-700 rounded-md ring-1 ring-black ring-opacity-5"
className="rounded-md bg-gray-700 py-1 ring-1 ring-black ring-opacity-5"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu"
@@ -77,7 +77,7 @@ const UserDropdown: React.FC = () => {
}}
onClick={() => setDropdownOpen(false)}
>
<UserIcon className="inline w-5 h-5 mr-2" />
<UserIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.myprofile)}</span>
</a>
</Link>
@@ -93,7 +93,7 @@ const UserDropdown: React.FC = () => {
}}
onClick={() => setDropdownOpen(false)}
>
<CogIcon className="inline w-5 h-5 mr-2" />
<CogIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.settings)}</span>
</a>
</Link>
@@ -103,7 +103,7 @@ const UserDropdown: React.FC = () => {
role="menuitem"
onClick={() => logout()}
>
<LogoutIcon className="inline w-5 h-5 mr-2" />
<LogoutIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.signout)}</span>
</a>
</div>

View File

@@ -11,8 +11,8 @@ import useSWR from 'swr';
import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces';
const messages = defineMessages({
streamdevelop: 'Jellyseerr Develop',
streamstable: 'Jellyseerr Stable',
streamdevelop: 'Overseerr Develop',
streamstable: 'Overseerr Stable',
outofdate: 'Out of Date',
commitsbehind:
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
@@ -50,20 +50,20 @@ const VersionStatus: React.FC<VersionStatusProps> = ({ onClick }) => {
}}
role="button"
tabIndex={0}
className={`flex items-center p-2 mx-2 text-xs transition duration-300 rounded-lg ring-1 ring-gray-700 ${
className={`mx-2 flex items-center rounded-lg p-2 text-xs ring-1 ring-gray-700 transition duration-300 ${
data.updateAvailable
? 'bg-yellow-500 text-white hover:bg-yellow-400'
: 'bg-gray-900 text-gray-300 hover:bg-gray-800'
}`}
>
{data.commitTag === 'local' ? (
<CodeIcon className="w-6 h-6" />
<CodeIcon className="h-6 w-6" />
) : data.version.startsWith('develop-') ? (
<BeakerIcon className="w-6 h-6" />
<BeakerIcon className="h-6 w-6" />
) : (
<ServerIcon className="w-6 h-6" />
<ServerIcon className="h-6 w-6" />
)}
<div className="flex flex-col flex-1 min-w-0 px-2 truncate last:pr-0">
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
<span className="font-bold">{versionStream}</span>
<span className="truncate">
{data.commitTag === 'local' ? (
@@ -75,13 +75,13 @@ const VersionStatus: React.FC<VersionStatusProps> = ({ onClick }) => {
) : data.commitsBehind === -1 ? (
intl.formatMessage(messages.outofdate)
) : (
<code className="p-0 bg-transparent">
<code className="bg-transparent p-0">
{data.version.replace('develop-', '')}
</code>
)}
</span>
</div>
{data.updateAvailable && <ArrowCircleUpIcon className="w-6 h-6" />}
{data.updateAvailable && <ArrowCircleUpIcon className="h-6 w-6" />}
</a>
</Link>
);

View File

@@ -45,14 +45,14 @@ const Layout: React.FC = ({ children }) => {
}, []);
return (
<div className="flex h-full min-w-0 min-h-full bg-gray-900">
<div className="fixed inset-0 z-20 w-full h-1 border-gray-700 md:border-t pwa-only" />
<div className="absolute top-0 w-full h-64 from-gray-800 to-gray-900 bg-gradient-to-bl">
<div className="relative inset-0 w-full h-full from-gray-900 to-transparent bg-gradient-to-t" />
<div className="flex h-full min-h-full min-w-0 bg-gray-900">
<div className="pwa-only fixed inset-0 z-20 h-1 w-full border-gray-700 md:border-t" />
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900">
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
</div>
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 lg:ml-64">
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
<div
className={`searchbar fixed left-0 right-0 top-0 z-10 flex flex-shrink-0 bg-opacity-80 transition duration-300 ${
isScrolled ? 'bg-gray-700' : 'bg-transparent'
@@ -65,17 +65,17 @@ const Layout: React.FC = ({ children }) => {
<button
className={`px-4 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} focus:outline-none lg:hidden transition duration-300`}
} transition duration-300 focus:outline-none lg:hidden`}
aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)}
>
<MenuAlt2Icon className="w-6 h-6" />
<MenuAlt2Icon className="h-6 w-6" />
</button>
<div className="flex items-center justify-between flex-1 pr-4 md:pr-4 md:pl-4">
<div className="flex flex-1 items-center justify-between pr-4 md:pr-4 md:pl-4">
<button
className={`mr-2 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} transition duration-300 hover:text-white pwa-only focus:outline-none focus:text-white`}
} pwa-only transition duration-300 hover:text-white focus:text-white focus:outline-none`}
onClick={() => router.back()}
>
<ArrowLeftIcon className="w-7" />
@@ -87,9 +87,9 @@ const Layout: React.FC = ({ children }) => {
</div>
</div>
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
<main className="relative top-16 z-0 focus:outline-none" tabIndex={0}>
<div className="mb-6">
<div className="px-4 mx-auto max-w-8xl">{children}</div>
<div className="max-w-8xl mx-auto px-4">{children}</div>
</div>
</main>
</div>

View File

@@ -11,12 +11,12 @@ interface BarProps {
const Bar = ({ progress, isFinished }: BarProps) => {
return (
<div
className={`fixed top-0 left-0 z-50 w-full transition-opacity ease-out duration-400 ${
className={`duration-400 fixed top-0 left-0 z-50 w-full transition-opacity ease-out ${
isFinished ? 'opacity-0' : 'opacity-100'
}`}
>
<div
className="duration-300 bg-indigo-400 transition-width"
className="bg-indigo-400 transition-width duration-300"
style={{
height: '3px',
width: `${progress * 100}%`,

View File

@@ -90,7 +90,7 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
@@ -7,7 +7,6 @@ import * as Yup from 'yup';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import useSettings from '../../hooks/useSettings';
import AddEmailModal from './AddEmailModal';
const messages = defineMessages({
username: 'Username',
@@ -38,9 +37,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate,
initial,
}) => {
const [requiresEmail, setRequiresEmail] = useState<number>(0);
const [username, setUsername] = useState<string>();
const [password, setPassword] = useState<string>();
const toasts = useToasts();
const intl = useIntl();
const settings = useSettings();
@@ -103,7 +99,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<label htmlFor="host" className="text-label">
{intl.formatMessage(messages.host)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="host"
@@ -119,7 +115,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"
@@ -135,7 +131,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="username"
@@ -151,8 +147,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="shadow-sm flexrounded-md">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flexrounded-md shadow-sm">
<Field
id="password"
name="password"
@@ -165,7 +161,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
)}
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="inline-flex rounded-md shadow-sm">
<Button
@@ -195,14 +191,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
});
return (
<div>
{requiresEmail == 1 && (
<AddEmailModal
username={username ?? ''}
password={password ?? ''}
onSave={revalidate}
onClose={() => setRequiresEmail(0)}
></AddEmailModal>
)}
<Formik
initialValues={{
username: '',
@@ -214,25 +202,20 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
email: values.username,
});
} catch (e) {
if (e.message === 'Request failed with status code 406') {
setUsername(values.username);
setPassword(values.password);
setRequiresEmail(1);
} else {
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{
autoDismiss: true,
appearance: 'error',
}
);
}
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{
autoDismiss: true,
appearance: 'error',
}
);
} finally {
revalidate();
}
@@ -246,7 +229,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="username"
@@ -262,7 +245,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="password"
@@ -276,7 +259,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
)}
</div>
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button

View File

@@ -10,6 +10,7 @@ import Button from '../Common/Button';
import SensitiveInput from '../Common/SensitiveInput';
const messages = defineMessages({
username: 'Username',
email: 'Email Address',
password: 'Password',
validationemailrequired: 'You must provide a valid email address',
@@ -30,9 +31,9 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
const [loginError, setLoginError] = useState<string | null>(null);
const LoginSchema = Yup.object().shape({
email: Yup.string()
.email()
.required(intl.formatMessage(messages.validationemailrequired)),
email: Yup.string().required(
intl.formatMessage(messages.validationemailrequired)
),
password: Yup.string().required(
intl.formatMessage(messages.validationpasswordrequired)
),
@@ -68,9 +69,11 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
<Form>
<div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
{intl.formatMessage(messages.email) +
' / ' +
intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<Field
id="email"
@@ -86,7 +89,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -101,12 +104,12 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
)}
</div>
{loginError && (
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="error">{loginError}</div>
</div>
)}
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button

View File

@@ -3,6 +3,7 @@ import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
@@ -63,26 +64,28 @@ const Login: React.FC = () => {
}
}, [user, router]);
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
});
return (
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.signin)} />
<ImageFader
forceOptimize
backgroundImages={[
'/images/rotate1.jpg',
'/images/rotate2.jpg',
'/images/rotate3.jpg',
'/images/rotate4.jpg',
'/images/rotate5.jpg',
'/images/rotate6.jpg',
]}
backgroundImages={
backdrops?.map(
(backdrop) => `https://www.themoviedb.org/t/p/original${backdrop}`
) ?? []
}
/>
<div className="absolute z-50 top-4 right-4">
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="relative z-40 flex flex-col items-center px-4 mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="max-w-full mb-10" alt="Logo" />
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<div className="relative z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="mb-10 max-w-full" alt="Logo" />
<h2 className="mt-2 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.signinheader)}
</h2>
</div>
@@ -101,10 +104,10 @@ const Login: React.FC = () => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="p-4 mb-4 bg-red-600 rounded-md">
<div className="mb-4 rounded-md bg-red-600 p-4">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="w-5 h-5 text-red-300" />
<XCircleIcon className="h-5 w-5 text-red-300" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-300">
@@ -118,11 +121,11 @@ const Login: React.FC = () => {
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`font-bold w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 sm:rounded-t-lg ${
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${
settings.currentSettings.localLogin &&
'hover:bg-gray-700 hover:cursor-pointer'
'hover:cursor-pointer hover:bg-gray-700'
}`}
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
@@ -148,7 +151,7 @@ const Login: React.FC = () => {
{settings.currentSettings.localLogin && (
<div>
<button
className={`font-bold w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-800 cursor-default focus:outline-none bg-opacity-70 hover:bg-gray-700 hover:cursor-pointer ${
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'

View File

@@ -0,0 +1,506 @@
import { ServerIcon, ViewListIcon } from '@heroicons/react/outline';
import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid';
import axios from 'axios';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { IssueStatus } from '../../../server/constants/issue';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import { MediaWatchDataResponse } from '../../../server/interfaces/api/mediaInterfaces';
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}',
manageModalIssues: 'Open Issues',
manageModalRequests: 'Requests',
manageModalMedia: 'Media',
manageModalMedia4k: '4K Media',
manageModalAdvanced: 'Advanced',
manageModalNoRequests: 'No requests.',
manageModalClearMedia: 'Clear 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 in {arr}',
openarr4k: 'Open in 4K {arr}',
downloadstatus: 'Downloads',
markavailable: 'Mark as Available',
mark4kavailable: 'Mark as Available in 4K',
markallseasonsavailable: 'Mark All Seasons as Available',
markallseasons4kavailable: 'Mark All Seasons as Available in 4K',
opentautulli: 'Open in Tautulli',
plays:
'<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}',
pastdays: 'Past {days, number} Days',
alltime: 'All Time',
playedby: 'Played By',
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 { user: currentUser, hasPermission } = useUser();
const intl = useIntl();
const settings = useSettings();
const { data: watchData } = useSWR<MediaWatchDataResponse>(
data.mediaInfo && hasPermission(Permission.ADMIN)
? `/api/v1/media/${data.mediaInfo.id}/watch_data`
: null
);
const deleteMedia = async () => {
if (data.mediaInfo) {
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
revalidate();
}
};
const markAvailable = async (is4k = false) => {
if (data.mediaInfo) {
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
is4k,
});
revalidate();
}
};
const requests =
data.mediaInfo?.requests?.filter(
(request) => request.status !== MediaRequestStatus.DECLINED
) ?? [];
const openIssues =
data.mediaInfo?.issues?.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? [];
const styledPlayCount = (playCount: number): JSX.Element => {
return (
<>
{intl.formatMessage(messages.plays, {
playCount,
strong: function strong(msg) {
return <strong className="text-2xl font-semibold">{msg}</strong>;
},
})}
</>
);
};
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}
>
<div className="space-y-6">
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="overflow-hidden rounded-md bg-gray-600 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>
</div>
)}
{hasPermission([Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], {
type: 'or',
}) &&
openIssues.length > 0 && (
<>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalIssues)}
</h3>
<div className="overflow-hidden rounded-md bg-gray-600 shadow">
<ul>
{openIssues.map((issue) => (
<li
key={`manage-issue-${issue.id}`}
className="border-b border-gray-700 last:border-b-0"
>
<IssueBlock issue={issue} />
</li>
))}
</ul>
</div>
</>
)}
{requests.length > 0 && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden rounded-md bg-gray-600 shadow">
<ul>
{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>
))}
</ul>
</div>
</div>
)}
{hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl ||
data.mediaInfo?.tautulliUrl ||
watchData?.data?.playCount) && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalMedia)}
</h3>
<div className="space-y-2">
{!!watchData?.data && (
<div>
<div
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden bg-gray-600 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl
? 'rounded-t-md'
: 'rounded-md'
}`}
>
<div className="grid grid-cols-3 divide-x divide-gray-500">
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, { days: 7 })}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount7Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 30,
})}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount30Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.alltime)}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount)}
</div>
</div>
</div>
{!!watchData.data.users.length && (
<div className="flex flex-row space-x-2 px-4 pt-3 pb-2">
<span className="shrink-0 font-bold leading-8">
{intl.formatMessage(messages.playedby)}
</span>
<span className="flex flex-row flex-wrap">
{watchData.data.users.map((user) => (
<Link
href={
currentUser?.id === user.id
? '/profile'
: `/users/${user.id}`
}
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
))}
</span>
</div>
)}
</div>
{data.mediaInfo?.tautulliUrl && (
<a
href={data.mediaInfo.tautulliUrl}
target="_blank"
rel="noreferrer"
>
<Button
buttonType="ghost"
className={`w-full ${
watchData.data.playCount ? 'rounded-t-none' : ''
}`}
>
<ViewListIcon />
<span>
{intl.formatMessage(messages.opentautulli)}
</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
rel="noreferrer"
className="block"
>
<Button buttonType="ghost" className="w-full">
<ServerIcon />
<span>
{intl.formatMessage(messages.openarr, {
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</Button>
</a>
)}
</div>
</div>
)}
{hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl4k ||
data.mediaInfo?.tautulliUrl4k ||
watchData?.data4k?.playCount) && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalMedia4k)}
</h3>
<div className="space-y-2">
{!!watchData?.data4k && (
<div>
<div
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden bg-gray-600 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl4k
? 'rounded-t-md'
: 'rounded-md'
}`}
>
<div className="grid grid-cols-3 divide-x divide-gray-500">
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, { days: 7 })}
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount7Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 30,
})}
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount30Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.alltime)}
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount)}
</div>
</div>
</div>
{!!watchData.data4k.users.length && (
<div className="flex flex-row space-x-2 px-4 pt-3 pb-2">
<span className="shrink-0 font-bold leading-8">
{intl.formatMessage(messages.playedby)}
</span>
<span className="flex flex-row flex-wrap">
{watchData.data4k.users.map((user) => (
<Link
href={
currentUser?.id === user.id
? '/profile'
: `/users/${user.id}`
}
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
))}
</span>
</div>
)}
</div>
{data.mediaInfo?.tautulliUrl4k && (
<a
href={data.mediaInfo.tautulliUrl4k}
target="_blank"
rel="noreferrer"
>
<Button
buttonType="ghost"
className={`w-full ${
watchData.data4k.playCount ? 'rounded-t-none' : ''
}`}
>
<ViewListIcon />
<span>
{intl.formatMessage(messages.opentautulli)}
</span>
</Button>
</a>
)}
</div>
)}
{data?.mediaInfo?.serviceUrl4k && (
<a
href={data?.mediaInfo?.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="block"
>
<Button buttonType="ghost" className="w-full">
<ServerIcon />
<span>
{intl.formatMessage(messages.openarr4k, {
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</Button>
</a>
)}
</div>
</div>
)}
{hasPermission(Permission.ADMIN) && data?.mediaInfo && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalAdvanced)}
</h3>
<div className="space-y-2">
{data?.mediaInfo.status !== MediaStatus.AVAILABLE && (
<Button
onClick={() => markAvailable()}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.markavailable
: messages.markallseasonsavailable
)}
</span>
</Button>
)}
{data?.mediaInfo.status4k !== MediaStatus.AVAILABLE &&
settings.currentSettings.series4kEnabled && (
<Button
onClick={() => markAvailable(true)}
className="w-full"
buttonType="success"
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
mediaType === 'movie'
? messages.mark4kavailable
: messages.markallseasons4kavailable
)}
</span>
</Button>
)}
<div>
<ConfirmButton
onClick={() => deleteMedia()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<span>
{intl.formatMessage(messages.manageModalClearMedia)}
</span>
</ConfirmButton>
<div className="mt-1 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? messages.movie : messages.tvshow
),
})}
</div>
</div>
</div>
</div>
)}
</div>
</SlideOver>
);
};
export default ManageSlideOver;

View File

@@ -32,16 +32,16 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
tabIndex={0}
>
<div
className={`relative w-36 sm:w-36 md:w-44
rounded-xl text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
className={`relative w-36 transform-gpu cursor-pointer
overflow-hidden rounded-xl text-white shadow-lg ring-1 transition duration-150 ease-in-out sm:w-36 md:w-44 ${
isHovered
? 'bg-gray-600 ring-gray-500 scale-105'
: 'bg-gray-800 ring-gray-700 scale-100'
? 'scale-105 bg-gray-600 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700'
}`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
<div className="relative z-10 flex flex-wrap items-center justify-center h-full opacity-30">
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30">
{posters[0] && (
<div className="w-1/2 p-1">
<img

View File

@@ -1,7 +1,7 @@
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import React, { useEffect } from 'react';
import { useSWRInfinite } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { MediaStatus } from '../../../server/constants/media';
import type {
MovieResult,

View File

@@ -1,23 +1,26 @@
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 { hasFlag } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useMemo, useState } from 'react';
import React, { useEffect, 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 { MediaServerType } from '../../../server/constants/server';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
@@ -34,23 +37,22 @@ 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';
const messages = defineMessages({
originaltitle: 'Original Title',
releasedate: 'Release Date',
releasedate:
'{releaseCount, plural, one {Release Date} other {Release Dates}}',
revenue: 'Revenue',
budget: 'Budget',
watchtrailer: 'Watch Trailer',
@@ -61,12 +63,6 @@ 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',
@@ -79,6 +75,8 @@ const messages = defineMessages({
showmore: 'Show More',
showless: 'Show Less',
streamingproviders: 'Currently Streaming On',
productioncountries:
'Production {countryCount, plural, one {Country} other {Countries}}',
});
interface MovieDetailsProps {
@@ -91,16 +89,20 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const router = useRouter();
const intl = useIntl();
const { locale } = useLocale();
const [showManager, setShowManager] = useState(false);
const [showManager, setShowManager] = useState(
router.query.manage == '1' ? true : 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}`,
{
initialData: movie,
}
);
const {
data,
error,
mutate: revalidate,
} = useSWR<MovieDetailsType>(`/api/v1/movie/${router.query.movieId}`, {
fallbackData: movie,
});
const { data: ratingData } = useSWR<RTRating>(
`/api/v1/movie/${router.query.movieId}/ratings`
@@ -111,6 +113,10 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
[data]
);
useEffect(() => {
setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -162,39 +168,34 @@ 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
? settings.currentSettings.region
: 'US';
const releases = data.releases.results.find(
(r) => r.iso_3166_1 === region
)?.release_dates;
// Release date types:
// 1. Premiere
// 2. Theatrical (limited)
// 3. Theatrical
// 4. Digital
// 5. Physical
// 6. TV
const filteredReleases = uniqBy(
releases?.filter((r) => r.type > 2 && r.type < 6),
'type'
);
const movieAttributes: React.ReactNode[] = [];
if (
data.releases.results.length &&
(data.releases.results.find((r) => r.iso_3166_1 === region)
?.release_dates[0].certification ||
data.releases.results[0].release_dates[0].certification)
) {
const certification = releases?.find((r) => r.certification)?.certification;
if (certification) {
movieAttributes.push(
<span className="p-0.5 py-0 border rounded-md">
{data.releases.results.find((r) => r.iso_3166_1 === region)
?.release_dates[0].certification ||
data.releases.results[0].release_dates[0].certification}
</span>
<span className="rounded-md border p-0.5 py-0">{certification}</span>
);
}
@@ -253,141 +254,25 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
<PageTitle title={data.title} />
<SlideOver
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="movie"
tmdbId={data.id}
/>
<ManageSlideOver
data={data}
mediaType="movie"
onClose={() => {
setShowManager(false);
router.push({
pathname: router.pathname,
query: { movieId: router.query.movieId },
});
}}
revalidate={() => revalidate()}
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
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>
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
@@ -408,11 +293,17 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
mediaUrl={data.mediaInfo?.mediaUrl}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.mediaUrl}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
[
Permission.MANAGE_REQUESTS,
Permission.REQUEST_4K,
Permission.REQUEST_4K_MOVIE,
],
{
type: 'or',
}
@@ -423,7 +314,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.mediaUrl4k}
/>
)}
</div>
@@ -441,7 +334,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>
@@ -454,13 +349,52 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
{(data.mediaInfo?.status === MediaStatus.AVAILABLE ||
(settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{
type: 'or',
}
) &&
data.mediaInfo?.status4k === MediaStatus.AVAILABLE)) &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Button
buttonType="warning"
className="ml-2 first:ml-0"
onClick={() => setShowIssueModal(true)}
>
<ExclamationIcon />
</Button>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
<Button
buttonType="default"
className="ml-2 first:ml-0"
className="relative ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<CogIcon />
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
)}
</div>
@@ -486,11 +420,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</li>
))}
</ul>
<div className="flex justify-end mt-4">
<div className="mt-4 flex justify-end">
<Link href={`/movie/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<ArrowCircleRightIcon className="inline-block w-5 h-5 ml-1.5" />
<ArrowCircleRightIcon className="ml-1.5 inline-block h-5 w-5" />
</a>
</Link>
</div>
@@ -502,7 +436,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<div className="mb-6">
<Link href={`/collection/${data.collection.id}`}>
<a>
<div className="relative z-0 overflow-hidden transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105 ring-1 ring-gray-700 hover:ring-gray-500">
<div className="group relative z-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-lg bg-gray-800 bg-cover bg-center shadow-md ring-1 ring-gray-700 transition duration-300 hover:scale-105 hover:ring-gray-500">
<div className="absolute inset-0 z-0">
<CachedImage
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
@@ -518,7 +452,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}}
/>
</div>
<div className="relative z-10 flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
<div className="relative z-10 flex h-14 items-center justify-between p-4 text-gray-200 transition duration-300 group-hover:text-white">
<div>{data.collection.name}</div>
<Button buttonSize="sm">
{intl.formatMessage(globalMessages.view)}
@@ -538,9 +472,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<>
<span className="media-rating">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
<RTRotten className="mr-1 w-6" />
) : (
<RTFresh className="w-6 mr-1" />
<RTFresh className="mr-1 w-6" />
)}
{ratingData.criticsScore}%
</span>
@@ -550,9 +484,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<>
<span className="media-rating">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
<RTAudRotten className="mr-1 w-6" />
) : (
<RTAudFresh className="w-6 mr-1" />
<RTAudFresh className="mr-1 w-6" />
)}
{ratingData.audienceScore}%
</span>
@@ -561,7 +495,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{!!data.voteCount && (
<>
<span className="media-rating">
<TmdbLogo className="w-6 mr-2" />
<TmdbLogo className="mr-2 w-6" />
{data.voteAverage}/10
</span>
</>
@@ -579,17 +513,66 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<span>{intl.formatMessage(globalMessages.status)}</span>
<span className="media-fact-value">{data.status}</span>
</div>
{data.releaseDate && (
{filteredReleases && filteredReleases.length > 0 ? (
<div className="media-fact">
<span>{intl.formatMessage(messages.releasedate)}</span>
<span className="media-fact-value">
{intl.formatDate(data.releaseDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
<span>
{intl.formatMessage(messages.releasedate, {
releaseCount: filteredReleases.length,
})}
</span>
<span className="media-fact-value">
{filteredReleases.map((r, i) => (
<span
className="flex items-center justify-end"
key={`release-date-${i}`}
>
{r.type === 3 ? (
// Theatrical
<TicketIcon className="h-4 w-4" />
) : r.type === 4 ? (
// Digital
<CloudIcon className="h-4 w-4" />
) : (
// Physical
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m12 2c-5.5242 0-10 4.4758-10 10 0 5.5242 4.4758 10 10 10 5.5242 0 10-4.4758 10-10 0-5.5242-4.4758-10-10-10zm0 18.065c-4.4476 0-8.0645-3.6169-8.0645-8.0645 0-4.4476 3.6169-8.0645 8.0645-8.0645 4.4476 0 8.0645 3.6169 8.0645 8.0645 0 4.4476-3.6169 8.0645-8.0645 8.0645zm0-14.516c-3.5565 0-6.4516 2.8952-6.4516 6.4516h1.2903c0-2.8468 2.3145-5.1613 5.1613-5.1613zm0 2.9032c-1.9597 0-3.5484 1.5887-3.5484 3.5484s1.5887 3.5484 3.5484 3.5484 3.5484-1.5887 3.5484-3.5484-1.5887-3.5484-3.5484-3.5484zm0 4.8387c-0.71371 0-1.2903-0.57661-1.2903-1.2903s0.57661-1.2903 1.2903-1.2903 1.2903 0.57661 1.2903 1.2903-0.57661 1.2903-1.2903 1.2903z"
fill="currentColor"
/>
</svg>
)}
<span className="ml-1.5">
{intl.formatDate(r.release_date, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</span>
))}
</span>
</div>
) : (
data.releaseDate && (
<div className="media-fact">
<span>
{intl.formatMessage(messages.releasedate, {
releaseCount: 1,
})}
</span>
<span className="media-fact-value">
{intl.formatDate(data.releaseDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
)
)}
{data.revenue > 0 && (
<div className="media-fact">
@@ -633,6 +616,37 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span>
</div>
)}
{data.productionCountries.length > 0 && (
<div className="media-fact">
<span>
{intl.formatMessage(messages.productioncountries, {
countryCount: data.productionCountries.length,
})}
</span>
<span className="media-fact-value">
{data.productionCountries.map((c) => {
return (
<span
className="flex items-center justify-end"
key={`prodcountry-${c.iso_3166_1}`}
>
{hasFlag(c.iso_3166_1) && (
<span
className={`mr-1.5 text-xs leading-5 flag:${c.iso_3166_1}`}
/>
)}
<span>
{intl.formatDisplayName(c.iso_3166_1, {
type: 'region',
fallback: 'none',
}) ?? c.name}
</span>
</span>
);
})}
</span>
</div>
)}
{data.productionCompanies.length > 0 && (
<div className="media-fact">
<span>
@@ -672,9 +686,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
: messages.showless
)}
{!showMoreStudios ? (
<ChevronDoubleDownIcon className="w-4 h-4 ml-1" />
<ChevronDoubleDownIcon className="ml-1 h-4 w-4" />
) : (
<ChevronDoubleUpIcon className="w-4 h-4 ml-1" />
<ChevronDoubleUpIcon className="ml-1 h-4 w-4" />
)}
</span>
</button>

View File

@@ -17,13 +17,13 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
return (
<>
<div
className={`relative flex items-start first:mt-0 mt-4 ${
className={`relative mt-4 flex items-start first:mt-0 ${
!!parent?.value && hasNotificationType(parent.value, currentTypes)
? 'opacity-50'
: ''
}`}
>
<div className="flex items-center h-6">
<div className="flex h-6 items-center">
<input
id={option.id}
name="permissions"
@@ -57,7 +57,7 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
</div>
</div>
{(option.children ?? []).map((child) => (
<div key={`notification-type-child-${child.id}`} className="pl-6 mt-4">
<div key={`notification-type-child-${child.id}`} className="mt-4 pl-6">
<NotificationType
option={child}
currentTypes={currentTypes}

View File

@@ -7,36 +7,58 @@ import NotificationType from './NotificationType';
const messages = defineMessages({
notificationTypes: 'Notification Types',
mediarequested: 'Media Requested',
mediarequested: 'Request Pending Approval',
mediarequestedDescription:
'Send notifications when users submit new media requests which require approval.',
usermediarequestedDescription:
'Get notified when other users submit new media requests which require approval.',
mediaapproved: 'Media Approved',
mediaapproved: 'Request Approved',
mediaapprovedDescription:
'Send notifications when media requests are manually approved.',
usermediaapprovedDescription:
'Get notified when your media requests are approved.',
mediaAutoApproved: 'Media Automatically Approved',
mediaAutoApproved: 'Request Automatically Approved',
mediaAutoApprovedDescription:
'Send notifications when users submit new media requests which are automatically approved.',
usermediaAutoApprovedDescription:
'Get notified when other users submit new media requests which are automatically approved.',
mediaavailable: 'Media Available',
mediaavailable: 'Request Available',
mediaavailableDescription:
'Send notifications when media requests become available.',
usermediaavailableDescription:
'Get notified when your media requests become available.',
mediafailed: 'Media Failed',
mediafailed: 'Request Processing Failed',
mediafailedDescription:
'Send notifications when media requests fail to be added to Radarr or Sonarr.',
usermediafailedDescription:
'Get notified when media requests fail to be added to Radarr or Sonarr.',
mediadeclined: 'Media Declined',
mediadeclined: 'Request Declined',
mediadeclinedDescription:
'Send notifications when media requests are declined.',
usermediadeclinedDescription:
'Get notified when your media requests are declined.',
issuecreated: 'Issue Reported',
issuecreatedDescription: 'Send notifications when issues are reported.',
userissuecreatedDescription: 'Get notified when other users report issues.',
issuecomment: 'Issue Comment',
issuecommentDescription:
'Send notifications when issues receive new comments.',
userissuecommentDescription:
'Get notified when issues you reported receive new comments.',
adminissuecommentDescription:
'Get notified when other users comment on issues.',
issueresolved: 'Issue Resolved',
issueresolvedDescription: 'Send notifications when issues are resolved.',
userissueresolvedDescription:
'Get notified when issues you reported are resolved.',
adminissueresolvedDescription:
'Get notified when issues are resolved by other users.',
issuereopened: 'Issue Reopened',
issuereopenedDescription: 'Send notifications when issues are reopened.',
userissuereopenedDescription:
'Get notified when issues you reported are reopened.',
adminissuereopenedDescription:
'Get notified when issues are reopened by other users.',
});
export const hasNotificationType = (
@@ -74,6 +96,10 @@ export enum Notification {
TEST_NOTIFICATION = 32,
MEDIA_DECLINED = 64,
MEDIA_AUTO_APPROVED = 128,
ISSUE_CREATED = 256,
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
}
export const ALL_NOTIFICATIONS = Object.values(Notification)
@@ -85,7 +111,7 @@ export interface NotificationItem {
name: string;
description: string;
value: Notification;
hasNotifyUser?: boolean;
hasNotifyUser: boolean;
children?: NotificationItem[];
hidden?: boolean;
}
@@ -173,6 +199,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
: messages.mediarequestedDescription
),
value: Notification.MEDIA_PENDING,
hasNotifyUser: false,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
@@ -184,6 +211,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
: messages.mediaAutoApprovedDescription
),
value: Notification.MEDIA_AUTO_APPROVED,
hasNotifyUser: false,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
@@ -231,6 +259,76 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
),
value: Notification.MEDIA_FAILED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
hasNotifyUser: false,
},
{
id: 'issue-created',
name: intl.formatMessage(messages.issuecreated),
description: intl.formatMessage(
user
? messages.userissuecreatedDescription
: messages.issuecreatedDescription
),
value: Notification.ISSUE_CREATED,
hidden: user && !hasPermission(Permission.MANAGE_ISSUES),
hasNotifyUser: false,
},
{
id: 'issue-comment',
name: intl.formatMessage(messages.issuecomment),
description: intl.formatMessage(
user
? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissuecommentDescription
: messages.userissuecommentDescription
: messages.issuecommentDescription
),
value: Notification.ISSUE_COMMENT,
hidden:
user &&
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
},
{
id: 'issue-resolved',
name: intl.formatMessage(messages.issueresolved),
description: intl.formatMessage(
user
? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissueresolvedDescription
: messages.userissueresolvedDescription
: messages.issueresolvedDescription
),
value: Notification.ISSUE_RESOLVED,
hidden:
user &&
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
},
{
id: 'issue-reopened',
name: intl.formatMessage(messages.issuereopened),
description: intl.formatMessage(
user
? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissuereopenedDescription
: messages.userissuereopenedDescription
: messages.issuereopenedDescription
),
value: Notification.ISSUE_REOPENED,
hidden:
user &&
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
},
];
@@ -259,7 +357,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
{intl.formatMessage(messages.notificationTypes)}
{!user && <span className="label-required">*</span>}
</span>
<div className="form-input">
<div className="form-input-area">
<div className="max-w-lg">
{availableTypes.map((type) => (
<NotificationType

View File

@@ -4,7 +4,9 @@ interface PWAHeaderProps {
applicationTitle?: string;
}
const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
const PWAHeader: React.FC<PWAHeaderProps> = ({
applicationTitle = 'Overseerr',
}) => {
return (
<>
<link
@@ -164,14 +166,7 @@ const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
href="/site.webmanifest"
crossOrigin="use-credentials"
/>
<meta
name="application-name"
content={applicationTitle ?? 'Jellyseerr'}
/>
<meta
name="apple-mobile-web-app-title"
content={applicationTitle ?? 'Jellyseerr'}
/>
<meta name="apple-mobile-web-app-title" content={applicationTitle} />
<meta
name="description"
content="Request and Media Discovery Application"
@@ -179,6 +174,7 @@ const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#1f2937" />
<meta name="application-name" content={applicationTitle} />
</>
);
};

View File

@@ -9,21 +9,24 @@ export const messages = defineMessages({
'Full administrator access. Bypasses all other permission checks.',
users: 'Manage Users',
usersDescription:
'Grant permission to manage Jellyseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
'Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.',
settings: 'Manage Settings',
settingsDescription:
'Grant permission to modify Jellyseerr settings. A user must have this permission to grant it to others.',
'Grant permission to modify global settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests',
managerequestsDescription:
'Grant permission to manage Jellyseerr requests. All requests made by a user with this permission will be automatically approved.',
'Grant permission to manage media requests. All requests made by a user with this permission will be automatically approved.',
request: 'Request',
requestDescription: 'Grant permission to request non-4K media.',
requestDescription: 'Grant permission to submit requests for non-4K media.',
requestMovies: 'Request Movies',
requestMoviesDescription: 'Grant permission to request non-4K movies.',
requestMoviesDescription:
'Grant permission to submit requests for non-4K movies.',
requestTv: 'Request Series',
requestTvDescription: 'Grant permission to request non-4K series.',
requestTvDescription:
'Grant permission to submit requests for non-4K series.',
autoapprove: 'Auto-Approve',
autoapproveDescription: 'Grant automatic approval for all non-4K requests.',
autoapproveDescription:
'Grant automatic approval for all non-4K media requests.',
autoapproveMovies: 'Auto-Approve Movies',
autoapproveMoviesDescription:
'Grant automatic approval for non-4K movie requests.',
@@ -31,7 +34,8 @@ export const messages = defineMessages({
autoapproveSeriesDescription:
'Grant automatic approval for non-4K series requests.',
autoapprove4k: 'Auto-Approve 4K',
autoapprove4kDescription: 'Grant automatic approval for all 4K requests.',
autoapprove4kDescription:
'Grant automatic approval for all 4K media requests.',
autoapprove4kMovies: 'Auto-Approve 4K Movies',
autoapprove4kMoviesDescription:
'Grant automatic approval for 4K movie requests.',
@@ -39,16 +43,25 @@ export const messages = defineMessages({
autoapprove4kSeriesDescription:
'Grant automatic approval for 4K series requests.',
request4k: 'Request 4K',
request4kDescription: 'Grant permission to request 4K media.',
request4kDescription: 'Grant permission to submit requests for 4K media.',
request4kMovies: 'Request 4K Movies',
request4kMoviesDescription: 'Grant permission to request 4K movies.',
request4kMoviesDescription:
'Grant permission to submit requests for 4K movies.',
request4kTv: 'Request 4K Series',
request4kTvDescription: 'Grant permission to request 4K series.',
request4kTvDescription: 'Grant permission to submit requests for 4K series.',
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grant permission to use advanced request options.',
'Grant permission to modify advanced media request options.',
viewrequests: 'View Requests',
viewrequestsDescription: "Grant permission to view other users' requests.",
viewrequestsDescription:
'Grant permission to view media requests submitted by other users.',
manageissues: 'Manage Issues',
manageissuesDescription: 'Grant permission to manage media issues.',
createissues: 'Report Issues',
createissuesDescription: 'Grant permission to report media issues.',
viewissues: 'View Issues',
viewissuesDescription:
'Grant permission to view media issues reported by other users.',
});
interface PermissionEditProps {
@@ -223,6 +236,26 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
},
],
},
{
id: 'manageissues',
name: intl.formatMessage(messages.manageissues),
description: intl.formatMessage(messages.manageissuesDescription),
permission: Permission.MANAGE_ISSUES,
children: [
{
id: 'createissues',
name: intl.formatMessage(messages.createissues),
description: intl.formatMessage(messages.createissuesDescription),
permission: Permission.CREATE_ISSUES,
},
{
id: 'viewissues',
name: intl.formatMessage(messages.viewissues),
description: intl.formatMessage(messages.viewissuesDescription),
permission: Permission.VIEW_ISSUES,
},
],
},
];
return (

View File

@@ -107,11 +107,11 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
return (
<>
<div
className={`relative flex items-start first:mt-0 mt-4 ${
className={`relative mt-4 flex items-start first:mt-0 ${
disabled ? 'opacity-50' : ''
}`}
>
<div className="flex items-center h-6">
<div className="flex h-6 items-center">
<input
id={option.id}
name="permissions"
@@ -139,7 +139,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
</div>
</div>
{(option.children ?? []).map((child) => (
<div key={`permission-child-${child.id}`} className="pl-10 mt-4">
<div key={`permission-child-${child.id}`} className="mt-4 pl-10">
<PermissionOption
option={child}
currentPermission={currentPermission}

View File

@@ -39,17 +39,17 @@ const PersonCard: React.FC<PersonCardProps> = ({
<div
className={`relative ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
} rounded-xl text-white shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
} transform-gpu cursor-pointer rounded-xl text-white shadow ring-1 transition duration-150 ease-in-out ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500'
: 'bg-gray-800 scale-100 ring-gray-700'
? 'scale-105 bg-gray-700 ring-gray-500'
: 'scale-100 bg-gray-800 ring-gray-700'
}`}
>
<div style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
<div className="relative flex justify-center w-full mt-2 mb-4 h-1/2">
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div className="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
{profilePath ? (
<div className="relative w-3/4 h-full overflow-hidden rounded-full ring-1 ring-gray-700">
<div className="relative h-full w-3/4 overflow-hidden rounded-full ring-1 ring-gray-700">
<CachedImage
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
alt=""
@@ -61,12 +61,12 @@ const PersonCard: React.FC<PersonCardProps> = ({
<UserCircleIcon className="h-full" />
)}
</div>
<div className="w-full font-bold text-center truncate">
<div className="w-full truncate text-center font-bold">
{name}
</div>
{subName && (
<div
className="overflow-hidden text-sm text-center text-gray-300 whitespace-normal"
className="overflow-hidden whitespace-normal text-center text-sm text-gray-300"
style={{
WebkitLineClamp: 2,
display: '-webkit-box',

View File

@@ -5,7 +5,7 @@ import { defineMessages, useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
import type { PersonDetail } from '../../../server/models/Person';
import type { PersonDetails as PersonDetailsType } from '../../../server/models/Person';
import Ellipsis from '../../assets/ellipsis.svg';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
@@ -27,7 +27,7 @@ const messages = defineMessages({
const PersonDetails: React.FC = () => {
const intl = useIntl();
const router = useRouter();
const { data, error } = useSWR<PersonDetail>(
const { data, error } = useSWR<PersonDetailsType>(
`/api/v1/person/${router.query.personId}`
);
const [showBio, setShowBio] = useState(false);
@@ -145,7 +145,7 @@ const PersonDetails: React.FC = () => {
canExpand
/>
{media.character && (
<div className="w-full mt-2 text-xs text-center text-gray-300 truncate">
<div className="mt-2 w-full truncate text-center text-xs text-gray-300">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
@@ -185,7 +185,7 @@ const PersonDetails: React.FC = () => {
canExpand
/>
{media.job && (
<div className="w-full mt-2 text-xs text-center text-gray-300 truncate">
<div className="mt-2 w-full truncate text-center text-xs text-gray-300">
{media.job}
</div>
)}
@@ -214,12 +214,12 @@ const PersonDetails: React.FC = () => {
</div>
)}
<div
className={`relative z-10 flex flex-col items-center mt-4 mb-8 lg:flex-row ${
className={`relative z-10 mt-4 mb-8 flex flex-col items-center lg:flex-row ${
data.biography ? 'lg:items-start' : ''
}`}
>
{data.profilePath && (
<div className="relative flex-shrink-0 mb-6 mr-0 overflow-hidden rounded-full w-36 h-36 lg:w-44 lg:h-44 lg:mb-0 lg:mr-6 ring-1 ring-gray-700">
<div className="relative mb-6 mr-0 h-36 w-36 flex-shrink-0 overflow-hidden rounded-full ring-1 ring-gray-700 lg:mb-0 lg:mr-6 lg:h-44 lg:w-44">
<CachedImage
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
alt=""
@@ -249,7 +249,7 @@ const PersonDetails: React.FC = () => {
<div className="relative text-left">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div
className="outline-none group ring-0"
className="group outline-none ring-0"
onClick={() => setShowBio((show) => !show)}
role="button"
tabIndex={-1}
@@ -257,7 +257,7 @@ const PersonDetails: React.FC = () => {
<TruncateMarkup
lines={showBio ? 200 : 6}
ellipsis={
<Ellipsis className="relative inline-block ml-2 -top-0.5 opacity-70 group-hover:opacity-100 transition duration-300" />
<Ellipsis className="relative -top-0.5 ml-2 inline-block opacity-70 transition duration-300 group-hover:opacity-100" />
}
>
<p className="pt-2 text-sm lg:text-base">{data.biography}</p>

View File

@@ -56,7 +56,7 @@ const QuotaSelector: React.FC<QuotaSelectorProps> = ({
{
quotaLimit: (
<select
className="inline short"
className="short inline"
value={limitOverride ?? quotaLimit}
onChange={(e) => setQuotaLimit(Number(e.target.value))}
disabled={isDisabled}
@@ -73,7 +73,7 @@ const QuotaSelector: React.FC<QuotaSelectorProps> = ({
),
quotaDays: (
<select
className="inline short"
className="short inline"
value={dayOverride ?? quotaDays}
onChange={(e) => setQuotaDays(Number(e.target.value))}
disabled={isDisabled}

View File

@@ -81,13 +81,13 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
{({ open }) => (
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<Listbox.Button className="focus:shadow-outline-blue relative flex w-full cursor-default items-center rounded-md border border-gray-500 bg-gray-700 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
{((selectedRegion && hasFlag(selectedRegion?.iso_3166_1)) ||
(isUserSetting &&
!selectedRegion &&
currentSettings.region &&
hasFlag(currentSettings.region))) && (
<span className="h-4 mr-2 overflow-hidden text-base leading-4">
<span className="mr-2 h-4 overflow-hidden text-base leading-4">
<span
className={`flag:${
selectedRegion
@@ -108,8 +108,8 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
})
: intl.formatMessage(messages.regionDefault)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500 pointer-events-none">
<ChevronDownIcon className="w-5 h-5" />
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500">
<ChevronDownIcon className="h-5 w-5" />
</span>
</Listbox.Button>
</span>
@@ -119,19 +119,19 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute w-full mt-1 bg-gray-800 rounded-md shadow-lg"
className="absolute mt-1 w-full rounded-md bg-gray-800 shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
{isUserSetting && (
<Listbox.Option value={null}>
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
} relative flex cursor-default select-none items-center py-2 pl-8 pr-4`}
>
<span className="mr-2 text-base">
<span
@@ -159,7 +159,7 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
<CheckIcon className="h-5 w-5" />
</span>
)}
</div>
@@ -170,8 +170,8 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
} relative cursor-default select-none py-2 pl-8 pr-4`}
>
<span
className={`${
@@ -186,7 +186,7 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
<CheckIcon className="h-5 w-5" />
</span>
)}
</div>
@@ -197,8 +197,8 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
} relative flex cursor-default select-none items-center py-2 pl-8 pr-4`}
>
<span className="mr-2 text-base">
<span
@@ -222,7 +222,7 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
<CheckIcon className="h-5 w-5" />
</span>
)}
</div>

View File

@@ -14,6 +14,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { MediaRequestStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import useRequestOverride from '../../hooks/useRequestOverride';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
@@ -33,6 +34,7 @@ interface RequestBlockProps {
}
const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
const { user } = useUser();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
@@ -75,14 +77,20 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
setShowEditModal(false);
}}
/>
<div className="px-4 py-4 text-gray-300">
<div className="px-4 py-3 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 mb-1 flex-nowrap white">
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
<Link href={`/users/${request.requestedBy.id}`}>
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
<Link
href={
request.requestedBy.id === user?.id
? '/profile'
: `/users/${request.requestedBy.id}`
}
>
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{request.requestedBy.displayName}
</a>
</Link>
@@ -90,10 +98,16 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
{request.modifiedBy && (
<div className="flex flex-nowrap">
<EyeIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
<Link href={`/users/${request.modifiedBy.id}`}>
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
<Link
href={
request.modifiedBy.id === user?.id
? '/profile'
: `/users/${request.modifiedBy.id}`
}
>
<a className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{request.modifiedBy.displayName}
</a>
</Link>
@@ -101,7 +115,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
)}
</div>
<div className="flex flex-wrap flex-shrink-0 ml-2">
<div className="ml-2 flex flex-shrink-0 flex-wrap">
{request.status === MediaRequestStatus.PENDING && (
<>
<Button
@@ -142,7 +156,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<div className="flex items-center mr-6 text-sm leading-5">
<div className="mr-6 flex items-center text-sm leading-5">
{request.is4k && (
<span className="mr-1">
<Badge badgeType="warning">4K</Badge>
@@ -165,8 +179,8 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
)}
</div>
</div>
<div className="flex items-center mt-2 text-sm leading-5 sm:mt-0">
<CalendarIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<span>
{intl.formatDate(request.createdAt, {
year: 'numeric',
@@ -177,7 +191,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
</div>
{(request.seasons ?? []).length > 0 && (
<div className="flex flex-col mt-2 text-sm">
<div className="mt-2 flex flex-col text-sm">
<div className="mb-1 font-medium">
{intl.formatMessage(messages.seasons, {
seasonCount: request.seasons.length,
@@ -187,7 +201,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
{request.seasons.map((season) => (
<span
key={`season-${season.id}`}
className="inline-block mb-1 mr-2"
className="mb-1 mr-2 inline-block"
>
<Badge>{season.seasonNumber}</Badge>
</span>
@@ -200,7 +214,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="mt-4 mb-1 text-sm">
{intl.formatMessage(messages.requestoverrides)}
</div>
<ul className="px-2 text-xs bg-gray-800 divide-y divide-gray-700 rounded-md">
<ul className="divide-y divide-gray-700 rounded-md bg-gray-800 px-2 text-xs">
{server && (
<li className="flex justify-between px-1 py-2">
<span className="font-bold">

View File

@@ -114,7 +114,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
error: requestError,
mutate: revalidate,
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
initialData: request,
fallbackData: request,
});
const modifyRequest = async (type: 'approve' | 'decline') => {
@@ -279,7 +279,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
MediaStatus.UNKNOWN ? (
<Badge
badgeType="danger"
//href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
>
{intl.formatMessage(globalMessages.failed)}
</Badge>

View File

@@ -109,7 +109,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
initialData: request,
fallbackData: request,
}
);
@@ -281,7 +281,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
] === MediaStatus.UNKNOWN ? (
<Badge
badgeType="danger"
//href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
>
{intl.formatMessage(globalMessages.failed)}
</Badge>

View File

@@ -3,10 +3,9 @@ import { Listbox, Transition } from '@headlessui/react';
import { AdjustmentsIcon } from '@heroicons/react/outline';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { isEqual } from 'lodash';
import dynamic from 'next/dynamic';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import Select from 'react-select';
import useSWR from 'swr';
import type {
ServiceCommonServer,
@@ -19,12 +18,10 @@ import { formatBytes } from '../../../utils/numberHelpers';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
type OptionType = {
value: string;
value: number;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
advancedoptions: 'Advanced',
destinationserver: 'Destination Server',
@@ -150,21 +147,21 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
const defaultProfile = serverData.profiles.find(
(profile) =>
profile.id ===
(isAnime
(isAnime && serverData.server.activeAnimeProfileId
? serverData.server.activeAnimeProfileId
: serverData.server.activeProfileId)
);
const defaultFolder = serverData.rootFolders.find(
(folder) =>
folder.path ===
(isAnime
(isAnime && serverData.server.activeAnimeDirectory
? serverData.server.activeAnimeDirectory
: serverData.server.activeDirectory)
);
const defaultLanguage = serverData.languageProfiles?.find(
(language) =>
language.id ===
(isAnime
(isAnime && serverData.server.activeAnimeLanguageProfileId
? serverData.server.activeAnimeLanguageProfileId
: serverData.server.activeLanguageProfileId)
);
@@ -172,10 +169,15 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
? serverData.server.activeAnimeTags
: serverData.server.activeTags;
const applyOverrides =
defaultOverrides &&
((defaultOverrides.server === null && serverData.server.isDefault) ||
defaultOverrides.server === serverData.server.id);
if (
defaultProfile &&
defaultProfile.id !== selectedProfile &&
(!defaultOverrides || defaultOverrides.profile === null)
(!applyOverrides || defaultOverrides.profile === null)
) {
setSelectedProfile(defaultProfile.id);
}
@@ -183,7 +185,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
if (
defaultFolder &&
defaultFolder.path !== selectedFolder &&
(!defaultOverrides || defaultOverrides.folder === null)
(!applyOverrides || !defaultOverrides.folder)
) {
setSelectedFolder(defaultFolder.path ?? '');
}
@@ -191,7 +193,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
if (
defaultLanguage &&
defaultLanguage.id !== selectedLanguage &&
(!defaultOverrides || defaultOverrides.language === null)
(!applyOverrides || defaultOverrides.language === null)
) {
setSelectedLanguage(defaultLanguage.id);
}
@@ -199,7 +201,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
if (
defaultTags &&
!isEqual(defaultTags, selectedTags) &&
(!defaultOverrides || defaultOverrides.tags === null)
(!applyOverrides || defaultOverrides.tags === null)
) {
setSelectedTags(defaultTags);
}
@@ -215,7 +217,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
setSelectedProfile(defaultOverrides.profile);
}
if (defaultOverrides && defaultOverrides.folder != null) {
if (defaultOverrides && defaultOverrides.folder) {
setSelectedFolder(defaultOverrides.folder);
}
@@ -241,7 +243,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
profile: selectedProfile !== -1 ? selectedProfile : undefined,
server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
language: selectedLanguage ?? undefined,
language: selectedLanguage !== -1 ? selectedLanguage : undefined,
tags: selectedTags,
});
}
@@ -256,27 +258,37 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
if (!data && !error) {
return (
<div className="w-full mb-2">
<div className="mb-2 w-full">
<SmallLoadingSpinner />
</div>
);
}
if ((!data || selectedServer === null) && !selectedUser) {
if (
(!data ||
selectedServer === null ||
(data.filter((server) => server.is4k === is4k).length < 2 &&
(!serverData ||
(serverData.profiles.length < 2 &&
serverData.rootFolders.length < 2 &&
(serverData.languageProfiles ?? []).length < 2 &&
!serverData.tags?.length)))) &&
(!selectedUser || (userData?.results ?? []).length < 2)
) {
return null;
}
return (
<>
<div className="flex items-center mb-2 font-bold tracking-wider">
<AdjustmentsIcon className="w-5 h-5 mr-1.5" />
<div className="mt-4 mb-2 flex items-center font-bold tracking-wider">
<AdjustmentsIcon className="mr-1.5 h-5 w-5" />
{intl.formatMessage(messages.advancedoptions)}
</div>
<div className="p-4 bg-gray-600 rounded-md shadow">
<div className="rounded-md bg-gray-600 p-4 shadow">
{!!data && selectedServer !== null && (
<div className="flex flex-col md:flex-row">
{data.filter((server) => server.is4k === is4k).length > 1 && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="server">
{intl.formatMessage(messages.destinationserver)}
</label>
@@ -286,7 +298,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedServer}
onChange={(e) => setSelectedServer(Number(e.target.value))}
onBlur={(e) => setSelectedServer(Number(e.target.value))}
className="bg-gray-800 border-gray-700"
className="border-gray-700 bg-gray-800"
>
{data
.filter((server) => server.is4k === is4k)
@@ -308,7 +320,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
{(isValidating ||
!serverData ||
serverData.profiles.length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="profile">
{intl.formatMessage(messages.qualityprofile)}
</label>
@@ -318,7 +330,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedProfile}
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="bg-gray-800 border-gray-700"
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
@@ -352,7 +364,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
{(isValidating ||
!serverData ||
serverData.rootFolders.length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="folder">
{intl.formatMessage(messages.rootfolder)}
</label>
@@ -362,7 +374,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
value={selectedFolder}
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="bg-gray-800 border-gray-700"
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
@@ -406,7 +418,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
(isValidating ||
!serverData ||
(serverData.languageProfiles ?? []).length > 1) && (
<div className="flex-grow flex-shrink-0 w-full mb-3 md:w-1/4 md:pr-4 last:pr-0">
<div className="mb-3 w-full flex-shrink-0 flex-grow last:pr-0 md:w-1/4 md:pr-4">
<label htmlFor="language">
{intl.formatMessage(messages.languageprofile)}
</label>
@@ -420,7 +432,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
onBlur={(e) =>
setSelectedLanguage(parseInt(e.target.value))
}
className="bg-gray-800 border-gray-700"
className="border-gray-700 bg-gray-800"
disabled={isValidating || !serverData}
>
{(isValidating || !serverData) && (
@@ -459,7 +471,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
(isValidating || !serverData || !!serverData?.tags?.length) && (
<div className="mb-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
<Select
<Select<OptionType, true>
name="tags"
options={(serverData?.tags ?? []).map((tag) => ({
label: tag.label,
@@ -474,22 +486,26 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
}
className="react-select-container react-select-container-dark"
classNamePrefix="react-select"
value={selectedTags.map((tagId) => {
const foundTag = serverData?.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setSelectedTags(value?.map((option) => option.value));
value={
selectedTags
.map((tagId) => {
const foundTag = serverData?.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter((option) => option !== undefined) as OptionType[]
}
onChange={(value) => {
setSelectedTags(value.map((option) => option.value));
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
@@ -498,7 +514,8 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</div>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
selectedUser &&
(userData?.results ?? []).length > 1 && (
<Listbox
as="div"
value={selectedUser}
@@ -512,25 +529,25 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-800 border border-gray-700 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span className="flex items-center">
<img
src={selectedUser.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
className="h-6 w-6 flex-shrink-0 rounded-full"
/>
<span className="block ml-3">
<span className="ml-3 block">
{selectedUser.displayName}
</span>
{selectedUser.displayName.toLowerCase() !==
selectedUser.email && (
<span className="ml-1 text-gray-400 truncate">
<span className="ml-1 truncate text-gray-400">
({selectedUser.email})
</span>
)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500 pointer-events-none">
<ChevronDownIcon className="w-5 h-5" />
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2 text-gray-500">
<ChevronDownIcon className="h-5 w-5" />
</span>
</Listbox.Button>
</span>
@@ -543,11 +560,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
className="mt-1 w-full rounded-md bg-gray-800 shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
{userData?.results.map((user) => (
<Listbox.Option key={user.id} value={user}>
@@ -555,9 +572,9 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
<div
className={`${
active
? 'text-white bg-indigo-600'
? 'bg-indigo-600 text-white'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
} relative cursor-default select-none py-2 pl-8 pr-4`}
>
<span
className={`${
@@ -567,14 +584,14 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
<img
src={user.avatar}
alt=""
className="flex-shrink-0 w-6 h-6 rounded-full"
className="h-6 w-6 flex-shrink-0 rounded-full"
/>
<span className="flex-shrink-0 block ml-3">
<span className="ml-3 block flex-shrink-0">
{user.displayName}
</span>
{user.displayName.toLowerCase() !==
user.email && (
<span className="ml-1 text-gray-400 truncate">
<span className="ml-1 truncate text-gray-400">
({user.email})
</span>
)}
@@ -585,7 +602,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="w-5 h-5" />
<CheckIcon className="h-5 w-5" />
</span>
)}
</div>

View File

@@ -0,0 +1,476 @@
import { DownloadIcon } from '@heroicons/react/outline';
import axios from 'axios';
import React, { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import { Permission } from '../../../server/lib/permissions';
import { Collection } from '../../../server/models/Collection';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Badge from '../Common/Badge';
import CachedImage from '../Common/CachedImage';
import Modal from '../Common/Modal';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import QuotaDisplay from './QuotaDisplay';
const messages = defineMessages({
requestadmin: 'This request will be approved automatically.',
requestSuccess: '<strong>{title}</strong> requested successfully!',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
requesterror: 'Something went wrong while submitting the request.',
selectmovies: 'Select Movie(s)',
requestmovies: 'Request {count} {count, plural, one {Movie} other {Movies}}',
requestmovies4k:
'Request {count} {count, plural, one {Movie} other {Movies}} in 4K',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
tmdbId: number;
is4k?: boolean;
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
}
const CollectionRequestModal: React.FC<RequestModalProps> = ({
onCancel,
onComplete,
tmdbId,
onUpdating,
is4k = false,
}) => {
const [isUpdating, setIsUpdating] = useState(false);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
const [selectedParts, setSelectedParts] = useState<number[]>([]);
const { addToast } = useToasts();
const { data, error } = useSWR<Collection>(`/api/v1/collection/${tmdbId}`, {
revalidateOnMount: true,
});
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user &&
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
: null
);
const currentlyRemaining =
(quota?.movie.remaining ?? 0) - selectedParts.length;
const getAllParts = (): number[] => {
return (data?.parts ?? []).map((part) => part.id);
};
const getAllRequestedParts = (): number[] => {
const requestedParts = (data?.parts ?? []).reduce(
(requestedParts, part) => {
return [
...requestedParts,
...(part.mediaInfo?.requests ?? [])
.filter(
(request) =>
request.is4k === is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.map((part) => part.id),
];
},
[] as number[]
);
const availableParts = (data?.parts ?? [])
.filter(
(part) =>
part.mediaInfo &&
(part.mediaInfo[is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE ||
part.mediaInfo[is4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING) &&
!requestedParts.includes(part.id)
)
.map((part) => part.id);
return [...requestedParts, ...availableParts];
};
const isSelectedPart = (tmdbId: number): boolean =>
selectedParts.includes(tmdbId);
const togglePart = (tmdbId: number): void => {
// If this part already has a pending request, don't allow it to be toggled
if (getAllRequestedParts().includes(tmdbId)) {
return;
}
// If there are no more remaining requests available, block toggle
if (
quota?.movie.limit &&
currentlyRemaining <= 0 &&
!isSelectedPart(tmdbId)
) {
return;
}
if (selectedParts.includes(tmdbId)) {
setSelectedParts((parts) => parts.filter((partId) => partId !== tmdbId));
} else {
setSelectedParts((parts) => [...parts, tmdbId]);
}
};
const unrequestedParts = getAllParts().filter(
(tmdbId) => !getAllRequestedParts().includes(tmdbId)
);
const toggleAllParts = (): void => {
// If the user has a quota and not enough requests for all parts, block toggleAllParts
if (
quota?.movie.limit &&
(quota?.movie.remaining ?? 0) < unrequestedParts.length
) {
return;
}
if (
data &&
selectedParts.length >= 0 &&
selectedParts.length < unrequestedParts.length
) {
setSelectedParts(unrequestedParts);
} else {
setSelectedParts([]);
}
};
const isAllParts = (): boolean => {
if (!data) {
return false;
}
return (
selectedParts.length ===
getAllParts().filter((part) => !getAllRequestedParts().includes(part))
.length
);
};
const getPartRequest = (tmdbId: number): MediaRequest | undefined => {
const part = (data?.parts ?? []).find((part) => part.id === tmdbId);
return (part?.mediaInfo?.requests ?? []).find(
(request) =>
request.is4k === is4k && request.status !== MediaRequestStatus.DECLINED
);
};
useEffect(() => {
if (onUpdating) {
onUpdating(isUpdating);
}
}, [isUpdating, onUpdating]);
const sendRequest = useCallback(async () => {
setIsUpdating(true);
try {
let overrideParams = {};
if (requestOverrides) {
overrideParams = {
serverId: requestOverrides.server,
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
tags: requestOverrides.tags,
};
}
await Promise.all(
(
data?.parts.filter((part) => selectedParts.includes(part.id)) ?? []
).map(async (part) => {
await axios.post<MediaRequest>('/api/v1/request', {
mediaId: part.id,
mediaType: 'movie',
is4k,
...overrideParams,
});
})
);
if (onComplete) {
onComplete(
selectedParts.length === (data?.parts ?? []).length
? MediaStatus.UNKNOWN
: MediaStatus.PARTIALLY_AVAILABLE
);
}
addToast(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} catch (e) {
addToast(intl.formatMessage(messages.requesterror), {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
}
}, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]);
const hasAutoApprove = hasPermission(
[
Permission.MANAGE_REQUESTS,
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE,
],
{ type: 'or' }
);
return (
<Modal
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.name }
)}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
: selectedParts.length === 0
? intl.formatMessage(messages.selectmovies)
: intl.formatMessage(
is4k ? messages.requestmovies4k : messages.requestmovies,
{
count: selectedParts.length,
}
)
}
okDisabled={selectedParts.length === 0}
okButtonType={'primary'}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{hasAutoApprove && !quota?.movie.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.movie.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="movie"
quota={quota?.movie}
remaining={currentlyRemaining}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
aria-checked={isAllParts()}
onClick={() => toggleAllParts()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
toggleAllParts();
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
quota?.movie.limit &&
(quota.movie.remaining ?? 0) < unrequestedParts.length
? 'opacity-50'
: ''
}`}
>
<span
aria-hidden="true"
className={`${
isAllParts() ? 'bg-indigo-500' : 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isAllParts() ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.movie)}
</th>
<th className="bg-gray-500 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.status)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data?.parts.map((part) => {
const partRequest = getPartRequest(part.id);
const partMedia =
part.mediaInfo &&
part.mediaInfo[is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
? part.mediaInfo
: undefined;
return (
<tr key={`part-${part.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={
!!partMedia || isSelectedPart(part.id)
}
onClick={() => togglePart(part.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
togglePart(part.id);
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
!!partMedia ||
partRequest ||
(quota?.movie.limit &&
currentlyRemaining <= 0 &&
!isSelectedPart(part.id))
? 'opacity-50'
: ''
}`}
>
<span
aria-hidden="true"
className={`${
!!partMedia ||
partRequest ||
isSelectedPart(part.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
!!partMedia ||
partRequest ||
isSelectedPart(part.id)
? 'translate-x-5'
: 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
</td>
<td className="flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md">
<CachedImage
src={
part.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
objectFit="cover"
/>
</div>
<div className="flex flex-col justify-center pl-2">
<div className="text-xs font-medium">
{part.releaseDate?.slice(0, 4)}
</div>
<div className="text-base font-bold">
{part.title}
</div>
</div>
</td>
<td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6">
{!partMedia && !partRequest && (
<Badge>
{intl.formatMessage(globalMessages.notrequested)}
</Badge>
)}
{!partMedia &&
partRequest?.status ===
MediaRequestStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{((!partMedia &&
partRequest?.status ===
MediaRequestStatus.APPROVED) ||
partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PROCESSING) && (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{partMedia?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="movie"
is4k={is4k}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
};
export default CollectionRequestModal;

View File

@@ -46,7 +46,7 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
const [showDetails, setShowDetails] = useState(false);
return (
<div
className="flex flex-col p-4 my-4 bg-gray-800 rounded-md"
className="my-4 flex flex-col rounded-md bg-gray-800 p-4"
onClick={() => setShowDetails((s) => !s)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
@@ -58,7 +58,7 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
>
<div className="flex items-center">
<ProgressCircle
className="w-8 h-8"
className="h-8 w-8"
progress={Math.round(
((remaining ?? quota?.remaining ?? 0) / (quota?.limit ?? 1)) * 100
)}
@@ -85,11 +85,11 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
})}
</div>
</div>
<div className="flex justify-end flex-1">
<div className="flex flex-1 justify-end">
{showDetails ? (
<ChevronUpIcon className="w-6 h-6" />
<ChevronUpIcon className="h-6 w-6" />
) : (
<ChevronDownIcon className="w-6 h-6" />
<ChevronDownIcon className="h-6 w-6" />
)}
</div>
</div>
@@ -99,8 +99,8 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
<div className="mb-2">
{intl.formatMessage(
userOverride
? messages.requiredquota
: messages.requiredquotaUser,
? messages.requiredquotaUser
: messages.requiredquota,
{
seasons: overLimit,
strong: function strong(msg) {

View File

@@ -63,26 +63,26 @@ const SearchByNameModal: React.FC<SearchByNameModalProps> = ({
{data?.slice(0, 6).map((item) => (
<button
key={item.tvdbId}
className="container flex flex-col items-center justify-center h-40 mx-auto space-y-4 transition scale-100 outline-none cursor-pointer focus:ring focus:ring-indigo-500 focus:ring-opacity-70 focus:outline-none rounded-xl transform-gpu hover:scale-105"
className="container mx-auto flex h-40 scale-100 transform-gpu cursor-pointer flex-col items-center justify-center space-y-4 rounded-xl outline-none transition hover:scale-105 focus:outline-none focus:ring focus:ring-indigo-500 focus:ring-opacity-70"
onClick={() => handleClick(item.tvdbId)}
>
<div
className={`bg-gray-600 h-40 overflow-hidden w-full flex items-center p-2 rounded-xl shadow transition ${
className={`flex h-40 w-full items-center overflow-hidden rounded-xl bg-gray-600 p-2 shadow transition ${
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
} `}
>
<div className="flex items-center flex-none w-24 space-x-4">
<div className="flex w-24 flex-none items-center space-x-4">
<img
src={
item.remotePoster ??
'/images/overseerr_poster_not_found.png'
}
alt={item.title}
className="w-auto rounded-md h-100"
className="h-100 w-auto rounded-md"
/>
</div>
<div className="self-start flex-grow p-3 text-left">
<div className="text-sm font-medium text-grey-200">
<div className="flex-grow self-start p-3 text-left">
<div className="text-grey-200 text-sm font-medium">
{item.title}
</div>
<div className="h-24 overflow-hidden text-sm text-gray-400">

View File

@@ -2,12 +2,13 @@ import React from 'react';
import type { MediaStatus } from '../../../server/constants/media';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import Transition from '../Transition';
import CollectionRequestModal from './CollectionRequestModal';
import MovieRequestModal from './MovieRequestModal';
import TvRequestModal from './TvRequestModal';
interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv';
type: 'movie' | 'tv' | 'collection';
tmdbId: number;
is4k?: boolean;
editRequest?: MediaRequest;
@@ -45,7 +46,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
is4k={is4k}
editRequest={editRequest}
/>
) : (
) : type === 'tv' ? (
<TvRequestModal
onComplete={onComplete}
onCancel={onCancel}
@@ -54,6 +55,14 @@ const RequestModal: React.FC<RequestModalProps> = ({
is4k={is4k}
editRequest={editRequest}
/>
) : (
<CollectionRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
onUpdating={onUpdating}
is4k={is4k}
/>
)}
</Transition>
);

View File

@@ -32,7 +32,7 @@ const ResetPassword: React.FC = () => {
});
return (
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
<PageTitle title={intl.formatMessage(messages.passwordreset)} />
<ImageFader
forceOptimize
@@ -45,12 +45,12 @@ const ResetPassword: React.FC = () => {
'/images/rotate6.jpg',
]}
/>
<div className="absolute z-50 top-4 right-4">
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="relative z-40 flex flex-col items-center px-4 mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="max-w-full mb-10" alt="Logo" />
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<div className="relative z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="mb-10 max-w-full" alt="Logo" />
<h2 className="mt-2 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.resetpassword)}
</h2>
</div>
@@ -62,10 +62,10 @@ const ResetPassword: React.FC = () => {
<div className="px-10 py-8">
{hasSubmitted ? (
<>
<p className="text-gray-300 text-md">
<p className="text-md text-gray-300">
{intl.formatMessage(messages.requestresetlinksuccessmessage)}
</p>
<span className="flex justify-center mt-4 rounded-md shadow-sm">
<span className="mt-4 flex justify-center rounded-md shadow-sm">
<Link href="/login" passHref>
<Button as="a" buttonType="ghost">
<ArrowLeftIcon />
@@ -99,18 +99,18 @@ const ResetPassword: React.FC = () => {
<div>
<label
htmlFor="email"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
className="my-1 block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
inputMode="email"
className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
className="form-input-area block w-full min-w-0 flex-1 rounded-md border border-gray-500 bg-gray-700 text-white transition duration-150 ease-in-out sm:text-sm sm:leading-5"
/>
</div>
{errors.email && touched.email && (
@@ -118,7 +118,7 @@ const ResetPassword: React.FC = () => {
)}
</div>
</div>
<div className="pt-5 mt-4 border-t border-gray-700">
<div className="mt-4 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="inline-flex rounded-md shadow-sm">
<Button

View File

@@ -48,7 +48,7 @@ const ResetPassword: React.FC = () => {
});
return (
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
<ImageFader
forceOptimize
backgroundImages={[
@@ -60,12 +60,12 @@ const ResetPassword: React.FC = () => {
'/images/rotate6.jpg',
]}
/>
<div className="absolute z-50 top-4 right-4">
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="relative z-40 flex flex-col items-center px-4 mt-10 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="max-w-full mb-10" alt="Logo" />
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
<div className="relative z-40 mt-10 flex flex-col items-center px-4 sm:mx-auto sm:w-full sm:max-w-md">
<img src="/logo_stacked.svg" className="mb-10 max-w-full" alt="Logo" />
<h2 className="mt-2 text-center text-3xl font-extrabold leading-9 text-gray-100">
{intl.formatMessage(messages.resetpassword)}
</h2>
</div>
@@ -77,10 +77,10 @@ const ResetPassword: React.FC = () => {
<div className="px-10 py-8">
{hasSubmitted ? (
<>
<p className="text-gray-300 text-md">
<p className="text-md text-gray-300">
{intl.formatMessage(messages.resetpasswordsuccessmessage)}
</p>
<span className="flex justify-center mt-4 rounded-md shadow-sm">
<span className="mt-4 flex justify-center rounded-md shadow-sm">
<Link href="/login" passHref>
<Button as="a" buttonType="ghost">
{intl.formatMessage(messages.gobacklogin)}
@@ -114,11 +114,11 @@ const ResetPassword: React.FC = () => {
<div>
<label
htmlFor="password"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
className="my-1 block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -126,7 +126,7 @@ const ResetPassword: React.FC = () => {
name="password"
type="password"
autoComplete="new-password"
className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
className="form-input-area block w-full min-w-0 flex-1 rounded-md border border-gray-500 bg-gray-700 text-white transition duration-150 ease-in-out sm:text-sm sm:leading-5"
/>
</div>
{errors.password && touched.password && (
@@ -135,11 +135,11 @@ const ResetPassword: React.FC = () => {
</div>
<label
htmlFor="confirmPassword"
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
className="my-1 block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.confirmpassword)}
</label>
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -147,7 +147,7 @@ const ResetPassword: React.FC = () => {
name="confirmPassword"
type="password"
autoComplete="new-password"
className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
className="form-input-area block w-full min-w-0 flex-1 rounded-md border border-gray-500 bg-gray-700 text-white transition duration-150 ease-in-out sm:text-sm sm:leading-5"
/>
</div>
{errors.confirmPassword &&
@@ -158,7 +158,7 @@ const ResetPassword: React.FC = () => {
)}
</div>
</div>
<div className="pt-5 mt-4 border-t border-gray-700">
<div className="mt-4 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="inline-flex rounded-md shadow-sm">
<Button

View File

@@ -13,9 +13,9 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
onToggle,
}) => {
return (
<li className="flex col-span-1 rounded-md shadow-sm">
<div className="flex items-center justify-between flex-1 truncate bg-gray-600 border-t border-b border-r border-gray-700 rounded-md">
<div className="flex-1 px-4 py-6 text-sm leading-5 truncate cursor-default">
<li className="col-span-1 flex rounded-md shadow-sm">
<div className="flex flex-1 items-center justify-between truncate rounded-md border-t border-b border-r border-gray-700 bg-gray-600">
<div className="flex-1 cursor-default truncate px-4 py-6 text-sm leading-5">
{name}
</div>
<div className="flex-shrink-0 pr-2">
@@ -31,31 +31,31 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
}}
className={`${
isEnabled ? 'bg-indigo-600' : 'bg-gray-700'
} relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring`}
} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring`}
>
<span
aria-hidden="true"
className={`${
isEnabled ? 'translate-x-5' : 'translate-x-0'
} relative inline-block h-5 w-5 rounded-full bg-white shadow transform transition ease-in-out duration-200`}
} relative inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200 ease-in-out`}
>
<span
className={`${
isEnabled
? 'opacity-0 ease-out duration-100'
: 'opacity-100 ease-in duration-200'
} absolute inset-0 h-full w-full flex items-center justify-center transition-opacity`}
? 'opacity-0 duration-100 ease-out'
: 'opacity-100 duration-200 ease-in'
} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity`}
>
<XIcon className="w-3 h-3 text-gray-400" />
<XIcon className="h-3 w-3 text-gray-400" />
</span>
<span
className={`${
isEnabled
? 'opacity-100 ease-in duration-200'
: 'opacity-0 ease-out duration-100'
} absolute inset-0 h-full w-full flex items-center justify-center transition-opacity`}
? 'opacity-100 duration-200 ease-in'
: 'opacity-0 duration-100 ease-out'
} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity`}
>
<CheckIcon className="w-3 h-3 text-indigo-600" />
<CheckIcon className="h-3 w-3 text-indigo-600" />
</span>
</span>
</span>

View File

@@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import useSettings from '../../../hooks/useSettings';
import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner';
@@ -25,15 +26,19 @@ const messages = defineMessages({
toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL',
validationTypes: 'You must select at least one notification type',
enableMentions: 'Enable Mentions',
});
const NotificationsDiscord: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/discord'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/discord');
const NotificationsDiscordSchema = Yup.object().shape({
botAvatarUrl: Yup.string()
@@ -48,13 +53,6 @@ const NotificationsDiscord: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -69,6 +67,7 @@ const NotificationsDiscord: React.FC = () => {
botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl,
webhookUrl: data.options.webhookUrl,
enableMentions: data?.options.enableMentions,
}}
validationSchema={NotificationsDiscordSchema}
onSubmit={async (values) => {
@@ -80,6 +79,7 @@ const NotificationsDiscord: React.FC = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
});
@@ -127,6 +127,7 @@ const NotificationsDiscord: React.FC = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
enableMentions: values.enableMentions,
},
});
@@ -157,7 +158,7 @@ const NotificationsDiscord: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -182,7 +183,7 @@ const NotificationsDiscord: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="webhookUrl"
@@ -200,9 +201,14 @@ const NotificationsDiscord: React.FC = () => {
<label htmlFor="botUsername" className="text-label">
{intl.formatMessage(messages.botUsername)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="botUsername" name="botUsername" type="text" />
<Field
id="botUsername"
name="botUsername"
type="text"
placeholder={settings.currentSettings.applicationTitle}
/>
</div>
{errors.botUsername && touched.botUsername && (
<div className="error">{errors.botUsername}</div>
@@ -213,7 +219,7 @@ const NotificationsDiscord: React.FC = () => {
<label htmlFor="botAvatarUrl" className="text-label">
{intl.formatMessage(messages.botAvatarUrl)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="botAvatarUrl"
@@ -227,6 +233,18 @@ const NotificationsDiscord: React.FC = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="enableMentions" className="checkbox-label">
{intl.formatMessage(messages.enableMentions)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableMentions"
name="enableMentions"
/>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
@@ -238,14 +256,14 @@ const NotificationsDiscord: React.FC = () => {
}
}}
error={
errors.types && touched.types
? (errors.types as string)
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -262,11 +280,16 @@ const NotificationsDiscord: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>

View File

@@ -58,9 +58,11 @@ const NotificationsEmail: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/email'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/email');
const NotificationsEmailSchema = Yup.object().shape(
{
@@ -82,7 +84,7 @@ const NotificationsEmail: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.matches(
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationSmtpHostRequired)
),
smtpPort: Yup.number().when('enabled', {
@@ -235,7 +237,7 @@ const NotificationsEmail: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -243,7 +245,7 @@ const NotificationsEmail: React.FC = () => {
<label htmlFor="senderName" className="text-label">
{intl.formatMessage(messages.senderName)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="senderName" name="senderName" type="text" />
</div>
@@ -254,7 +256,7 @@ const NotificationsEmail: React.FC = () => {
{intl.formatMessage(messages.emailsender)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="emailFrom"
@@ -273,7 +275,7 @@ const NotificationsEmail: React.FC = () => {
{intl.formatMessage(messages.smtpHost)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="smtpHost"
@@ -292,7 +294,7 @@ const NotificationsEmail: React.FC = () => {
{intl.formatMessage(messages.smtpPort)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
id="smtpPort"
name="smtpPort"
@@ -313,7 +315,7 @@ const NotificationsEmail: React.FC = () => {
{intl.formatMessage(messages.encryptionTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="encryption" name="encryption">
<option value="none">
@@ -336,7 +338,7 @@ const NotificationsEmail: React.FC = () => {
<label htmlFor="allowSelfSigned" className="checkbox-label">
{intl.formatMessage(messages.allowselfsigned)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="allowSelfSigned"
@@ -348,7 +350,7 @@ const NotificationsEmail: React.FC = () => {
<label htmlFor="authUser" className="text-label">
{intl.formatMessage(messages.authUser)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="authUser" name="authUser" type="text" />
</div>
@@ -358,7 +360,7 @@ const NotificationsEmail: React.FC = () => {
<label htmlFor="authPass" className="text-label">
{intl.formatMessage(messages.authPass)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -383,7 +385,7 @@ const NotificationsEmail: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -413,7 +415,7 @@ const NotificationsEmail: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -429,7 +431,7 @@ const NotificationsEmail: React.FC = () => {
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -446,7 +448,7 @@ const NotificationsEmail: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -0,0 +1,258 @@
import { BeakerIcon, SaveIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
agentenabled: 'Enable Agent',
url: 'Server URL',
token: 'Application Token',
validationUrlRequired: 'You must provide a valid URL',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationTokenRequired: 'You must provide an application token',
gotifysettingssaved: 'Gotify notification settings saved successfully!',
gotifysettingsfailed: 'Gotify notification settings failed to save.',
toastGotifyTestSending: 'Sending Gotify test notification…',
toastGotifyTestSuccess: 'Gotify test notification sent!',
toastGotifyTestFailed: 'Gotify test notification failed to send.',
validationTypes: 'You must select at least one notification type',
});
const NotificationsGotify: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/gotify');
const NotificationsGotifySchema = Yup.object().shape({
url: Yup.string()
.when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationUrlRequired)),
otherwise: Yup.string().nullable(),
})
.matches(
// eslint-disable-next-line no-useless-escape
/^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i,
intl.formatMessage(messages.validationUrlRequired)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
token: Yup.string().when('enabled', {
is: true,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationTokenRequired)),
otherwise: Yup.string().nullable(),
}),
});
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<Formik
initialValues={{
enabled: data?.enabled,
types: data?.types,
url: data?.options.url,
token: data?.options.token,
}}
validationSchema={NotificationsGotifySchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/gotify', {
enabled: values.enabled,
types: values.types,
options: {
url: values.url,
token: values.token,
},
});
addToast(intl.formatMessage(messages.gotifysettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.gotifysettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
try {
addToast(
intl.formatMessage(messages.toastGotifyTestSending),
{
autoDsmiss: false,
appearance: 'info',
},
(id) => {
toastId = id;
}
);
await axios.post('/api/v1/settings/notifications/gotify/test', {
enabled: true,
types: values.types,
options: {
url: values.url,
token: values.token,
},
});
if (toastId) {
removeToast(toastId);
}
addToast(intl.formatMessage(messages.toastGotifyTestSuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) {
if (toastId) {
removeToast(toastId);
}
addToast(intl.formatMessage(messages.toastGotifyTestFailed), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setIsTesting(false);
}
};
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="url" className="text-label">
{intl.formatMessage(messages.url)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="url" name="url" type="text" />
</div>
{errors.url && touched.url && (
<div className="error">{errors.url}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="token" className="text-label">
{intl.formatMessage(messages.token)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="token" name="token" type="text" />
</div>
{errors.token && touched.token && (
<div className="error">{errors.token}</div>
)}
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
<BeakerIcon />
<span>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
);
};
export default NotificationsGotify;

View File

@@ -31,9 +31,11 @@ const NotificationsLunaSea: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/lunasea'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/lunasea');
const NotificationsLunaSeaSchema = Yup.object().shape({
webhookUrl: Yup.string()
@@ -45,13 +47,6 @@ const NotificationsLunaSea: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationWebhookUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -150,7 +145,7 @@ const NotificationsLunaSea: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -175,7 +170,7 @@ const NotificationsLunaSea: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="webhookUrl"
@@ -200,7 +195,7 @@ const NotificationsLunaSea: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="profileName" name="profileName" type="text" />
</div>
@@ -217,14 +212,14 @@ const NotificationsLunaSea: React.FC = () => {
}
}}
error={
errors.types && touched.types
? (errors.types as string)
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -241,11 +236,16 @@ const NotificationsLunaSea: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>

View File

@@ -18,6 +18,7 @@ const messages = defineMessages({
accessTokenTip:
'Create a token from your <PushbulletSettingsLink>Account Settings</PushbulletSettingsLink>',
validationAccessTokenRequired: 'You must provide an access token',
channelTag: 'Channel Tag',
pushbulletSettingsSaved:
'Pushbullet notification settings saved successfully!',
pushbulletSettingsFailed: 'Pushbullet notification settings failed to save.',
@@ -31,9 +32,11 @@ const NotificationsPushbullet: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/pushbullet'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/pushbullet');
const NotificationsPushbulletSchema = Yup.object().shape({
accessToken: Yup.string().when('enabled', {
@@ -43,13 +46,6 @@ const NotificationsPushbullet: React.FC = () => {
.required(intl.formatMessage(messages.validationAccessTokenRequired)),
otherwise: Yup.string().nullable(),
}),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -62,6 +58,7 @@ const NotificationsPushbullet: React.FC = () => {
enabled: data?.enabled,
types: data?.types,
accessToken: data?.options.accessToken,
channelTag: data.options.channelTag,
}}
validationSchema={NotificationsPushbulletSchema}
onSubmit={async (values) => {
@@ -71,6 +68,7 @@ const NotificationsPushbullet: React.FC = () => {
types: values.types,
options: {
accessToken: values.accessToken,
channelTag: values.channelTag,
},
});
addToast(intl.formatMessage(messages.pushbulletSettingsSaved), {
@@ -115,6 +113,7 @@ const NotificationsPushbullet: React.FC = () => {
types: values.types,
options: {
accessToken: values.accessToken,
channelTag: values.channelTag,
},
});
@@ -145,7 +144,7 @@ const NotificationsPushbullet: React.FC = () => {
{intl.formatMessage(messages.agentEnabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -172,7 +171,7 @@ const NotificationsPushbullet: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -186,6 +185,16 @@ const NotificationsPushbullet: React.FC = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="channelTag" className="text-label">
{intl.formatMessage(messages.channelTag)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="channelTag" name="channelTag" type="text" />
</div>
</div>
</div>
<NotificationTypeSelector
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
@@ -197,14 +206,14 @@ const NotificationsPushbullet: React.FC = () => {
}
}}
error={
errors.types && touched.types
? (errors.types as string)
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -221,11 +230,16 @@ const NotificationsPushbullet: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>

View File

@@ -15,7 +15,7 @@ const messages = defineMessages({
agentenabled: 'Enable Agent',
accessToken: 'Application API Token',
accessTokenTip:
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Overseerr',
userToken: 'User or Group Key',
userTokenTip:
'Your 30-character <UsersGroupsLink>user or group identifier</UsersGroupsLink>',
@@ -33,9 +33,11 @@ const NotificationsPushover: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/pushover'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/pushover');
const NotificationsPushoverSchema = Yup.object().shape({
accessToken: Yup.string()
@@ -62,13 +64,6 @@ const NotificationsPushover: React.FC = () => {
/^[a-z\d]{30}$/i,
intl.formatMessage(messages.validationUserTokenRequired)
),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -167,7 +162,7 @@ const NotificationsPushover: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -193,7 +188,7 @@ const NotificationsPushover: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="accessToken" name="accessToken" type="text" />
</div>
@@ -223,7 +218,7 @@ const NotificationsPushover: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="userToken" name="userToken" type="text" />
</div>
@@ -243,14 +238,14 @@ const NotificationsPushover: React.FC = () => {
}
}}
error={
errors.types && touched.types
? (errors.types as string)
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -267,11 +262,16 @@ const NotificationsPushover: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>

View File

@@ -29,9 +29,11 @@ const NotificationsSlack: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/slack'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/slack');
const NotificationsSlackSchema = Yup.object().shape({
webhookUrl: Yup.string()
@@ -43,13 +45,6 @@ const NotificationsSlack: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationWebhookUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -145,7 +140,7 @@ const NotificationsSlack: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -170,7 +165,7 @@ const NotificationsSlack: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="webhookUrl"
@@ -195,14 +190,14 @@ const NotificationsSlack: React.FC = () => {
}
}}
error={
errors.types && touched.types
? (errors.types as string)
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -219,11 +214,16 @@ const NotificationsSlack: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>

View File

@@ -19,7 +19,7 @@ const messages = defineMessages({
'Allow users to also start a chat with your bot and configure their own notifications',
botAPI: 'Bot Authorization Token',
botApiTip:
'<CreateBotLink>Create a bot</CreateBotLink> for use with Jellyseerr',
'<CreateBotLink>Create a bot</CreateBotLink> for use with Overseerr',
chatId: 'Chat ID',
chatIdTip:
'Start a chat with your bot, add <GetIdBotLink>@get_id_bot</GetIdBotLink>, and issue the <code>/my_id</code> command',
@@ -38,9 +38,11 @@ const NotificationsTelegram: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/telegram'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/telegram');
const NotificationsTelegramSchema = Yup.object().shape({
botAPI: Yup.string().when('enabled', {
@@ -51,8 +53,8 @@ const NotificationsTelegram: React.FC = () => {
otherwise: Yup.string().nullable(),
}),
chatId: Yup.string()
.when('enabled', {
is: true,
.when(['enabled', 'types'], {
is: (enabled: boolean, types: number) => enabled && !!types,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationChatIdRequired)),
@@ -167,7 +169,7 @@ const NotificationsTelegram: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -207,7 +209,7 @@ const NotificationsTelegram: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -228,7 +230,7 @@ const NotificationsTelegram: React.FC = () => {
{intl.formatMessage(messages.botUsernameTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="botUsername" name="botUsername" type="text" />
</div>
@@ -260,7 +262,7 @@ const NotificationsTelegram: React.FC = () => {
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="chatId" name="chatId" type="text" />
</div>
@@ -276,7 +278,7 @@ const NotificationsTelegram: React.FC = () => {
{intl.formatMessage(messages.sendSilentlyTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="sendSilently" name="sendSilently" />
</div>
</div>
@@ -298,7 +300,7 @@ const NotificationsTelegram: React.FC = () => {
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -315,7 +317,7 @@ const NotificationsTelegram: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
toastWebPushTestSuccess: 'Web push test notification sent!',
toastWebPushTestFailed: 'Web push test notification failed to send.',
httpsRequirement:
'In order to receive web push notifications, Jellyseerr must be served over HTTPS.',
'In order to receive web push notifications, Overseerr must be served over HTTPS.',
});
const NotificationsWebPush: React.FC = () => {
@@ -26,9 +26,11 @@ const NotificationsWebPush: React.FC = () => {
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const [isHttps, setIsHttps] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/webpush'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/webpush');
useEffect(() => {
setIsHttps(window.location.protocol.startsWith('https'));
@@ -118,13 +120,13 @@ const NotificationsWebPush: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || isTesting}
@@ -141,7 +143,7 @@ const NotificationsWebPush: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -18,27 +18,38 @@ const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
const defaultPayload = {
notification_type: '{{notification_type}}',
event: '{{event}}',
subject: '{{subject}}',
message: '{{message}}',
image: '{{image}}',
email: '{{notifyuser_email}}',
username: '{{notifyuser_username}}',
avatar: '{{notifyuser_avatar}}',
'{{media}}': {
media_type: '{{media_type}}',
tmdbId: '{{media_tmdbid}}',
imdbId: '{{media_imdbid}}',
tvdbId: '{{media_tvdbid}}',
status: '{{media_status}}',
status4k: '{{media_status4k}}',
},
'{{extra}}': [],
'{{request}}': {
request_id: '{{request_id}}',
requestedBy_email: '{{requestedBy_email}}',
requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}',
},
'{{issue}}': {
issue_id: '{{issue_id}}',
issue_type: '{{issue_type}}',
issue_status: '{{issue_status}}',
reportedBy_email: '{{reportedBy_email}}',
reportedBy_username: '{{reportedBy_username}}',
reportedBy_avatar: '{{reportedBy_avatar}}',
},
'{{comment}}': {
comment_message: '{{comment_message}}',
commentedBy_email: '{{commentedBy_email}}',
commentedBy_username: '{{commentedBy_username}}',
commentedBy_avatar: '{{commentedBy_avatar}}',
},
'{{extra}}': [],
};
const messages = defineMessages({
@@ -63,9 +74,11 @@ const NotificationsWebhook: React.FC = () => {
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/webhook'
);
const {
data,
error,
mutate: revalidate,
} = useSWR('/api/v1/settings/notifications/webhook');
const NotificationsWebhookSchema = Yup.object().shape({
webhookUrl: Yup.string()
@@ -101,13 +114,6 @@ const NotificationsWebhook: React.FC = () => {
}
}
),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@@ -220,7 +226,7 @@ const NotificationsWebhook: React.FC = () => {
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
@@ -229,7 +235,7 @@ const NotificationsWebhook: React.FC = () => {
{intl.formatMessage(messages.webhookUrl)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="webhookUrl"
@@ -247,7 +253,7 @@ const NotificationsWebhook: React.FC = () => {
<label htmlFor="authHeader" className="text-label">
{intl.formatMessage(messages.authheader)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field id="authHeader" name="authHeader" type="text" />
</div>
@@ -258,7 +264,7 @@ const NotificationsWebhook: React.FC = () => {
{intl.formatMessage(messages.customJson)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<JSONEditor
name="webhook-json-payload"
@@ -312,14 +318,14 @@ const NotificationsWebhook: React.FC = () => {
}
}}
error={
errors.types && touched.types
? (errors.types as string)
values.enabled && !values.types && touched.types
? intl.formatMessage(messages.validationTypes)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
@@ -336,11 +342,16 @@ const NotificationsWebhook: React.FC = () => {
</span>
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={
isSubmitting ||
!isValid ||
isTesting ||
(values.enabled && !values.types)
}
>
<SaveIcon />
<span>

View File

@@ -1,10 +1,9 @@
import { PencilIcon, PlusIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { Field, Formik } from 'formik';
import dynamic from 'next/dynamic';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import type { RadarrSettings } from '../../../../server/lib/settings';
@@ -14,19 +13,17 @@ import SensitiveInput from '../../Common/SensitiveInput';
import Transition from '../../Transition';
type OptionType = {
value: string;
value: number;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
createradarr: 'Add New Radarr Server',
create4kradarr: 'Add New 4K Radarr Server',
editradarr: 'Edit Radarr Server',
edit4kradarr: 'Edit 4K Radarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname or IP address',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
@@ -63,10 +60,13 @@ const messages = defineMessages({
enableSearch: 'Enable Automatic Search',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
validationBaseUrlTrailingSlash: 'URL base must not end in a trailing slash',
notagoptions: 'No tags.',
selecttags: 'Select tags',
announced: 'Announced',
inCinemas: 'In Cinemas',
released: 'Released',
});
interface TestResponse {
@@ -82,6 +82,7 @@ interface TestResponse {
id: number;
label: string;
}[];
urlBase?: string;
}
interface RadarrModalProps {
@@ -112,7 +113,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number()
@@ -317,6 +318,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
port: values.port,
useSsl: values.ssl,
});
if (!values.baseUrl || values.baseUrl === '/') {
setFieldValue('baseUrl', testResponse.urlBase);
}
}
}}
secondaryDisabled={
@@ -350,7 +354,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
: messages.defaultserver
)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="isDefault" name="isDefault" />
</div>
</div>
@@ -358,7 +362,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="is4k" className="checkbox-label">
{intl.formatMessage(messages.server4k)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="is4k" name="is4k" />
</div>
</div>
@@ -367,7 +371,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.servername)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="name"
@@ -389,7 +393,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.hostname)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<span className="protocol">
{values.ssl ? 'https://' : 'http://'}
@@ -416,7 +420,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
id="port"
name="port"
@@ -437,7 +441,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="ssl" className="checkbox-label">
{intl.formatMessage(messages.ssl)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="ssl"
@@ -454,7 +458,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.apiKey)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -476,7 +480,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="baseUrl" className="text-label">
{intl.formatMessage(messages.baseUrl)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="baseUrl"
@@ -499,7 +503,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -537,7 +541,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -573,17 +577,22 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
{intl.formatMessage(messages.minimumAvailability)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="minimumAvailability"
name="minimumAvailability"
>
<option value="announced">Announced</option>
<option value="inCinemas">In Cinemas</option>
<option value="released">Released</option>
<option value="preDB">PreDB</option>
<option value="announced">
{intl.formatMessage(messages.announced)}
</option>
<option value="inCinemas">
{intl.formatMessage(messages.inCinemas)}
</option>
<option value="released">
{intl.formatMessage(messages.released)}
</option>
</Field>
</div>
{errors.minimumAvailability &&
@@ -598,8 +607,8 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input">
<Select
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
@@ -619,24 +628,30 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
}
className="react-select-container"
classNamePrefix="react-select"
value={values.tags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
value={
values.tags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[]
}
onChange={(value) => {
setFieldValue(
'tags',
value?.map((option) => option.value)
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
@@ -649,7 +664,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="externalUrl"
@@ -667,7 +682,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="syncEnabled" className="checkbox-label">
{intl.formatMessage(messages.syncEnabled)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="syncEnabled"
@@ -679,7 +694,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
<label htmlFor="enableSearch" className="checkbox-label">
{intl.formatMessage(messages.enableSearch)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="enableSearch"

View File

@@ -4,7 +4,6 @@ import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
import useSWR from 'swr';
import globalMessages from '../../../../i18n/globalMessages';
import Alert from '../../../Common/Alert';
import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
@@ -13,18 +12,16 @@ import Transition from '../../../Transition';
const messages = defineMessages({
releases: 'Releases',
releasedataMissing: 'Release data unavailable. Is GitHub down?',
versionChangelog: 'Version Changelog',
releasedataMissing: 'Release data is currently unavailable.',
versionChangelog: '{version} Changelog',
viewongithub: 'View on GitHub',
latestversion: 'Latest',
currentversion: 'Current Version',
currentversion: 'Current',
viewchangelog: 'View Changelog',
runningDevelopMessage:
'The latest changes to the <code>develop</code> branch of Jellyseerr are not shown below. Please see the commit history for this branch on <GithubLink>GitHub</GithubLink> for details.',
});
const REPO_RELEASE_API =
'https://api.github.com/repos/Fallenbagel/jellyseerr/releases?per_page=20';
'https://api.github.com/repos/fallenbagel/jellyseerr/releases?per_page=20';
interface GitHubRelease {
url: string;
@@ -58,8 +55,9 @@ const Release: React.FC<ReleaseProps> = ({
}) => {
const intl = useIntl();
const [isModalOpen, setModalOpen] = useState(false);
return (
<div className="flex flex-col px-4 py-2 bg-gray-800 rounded-md sm:flex-row">
<div className="flex w-full flex-col space-y-3 rounded-md bg-gray-800 px-4 py-2 shadow-md ring-1 ring-gray-700 sm:flex-row sm:space-y-0 sm:space-x-3">
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
@@ -72,7 +70,9 @@ const Release: React.FC<ReleaseProps> = ({
<Modal
onCancel={() => setModalOpen(false)}
iconSvg={<DocumentTextIcon />}
title={intl.formatMessage(messages.versionChangelog)}
title={intl.formatMessage(messages.versionChangelog, {
version: release.name,
})}
cancelText={intl.formatMessage(globalMessages.close)}
okText={intl.formatMessage(messages.viewongithub)}
onOk={() => {
@@ -84,38 +84,34 @@ const Release: React.FC<ReleaseProps> = ({
</div>
</Modal>
</Transition>
<div className="flex items-center justify-center mb-4 sm:mb-0 sm:justify-start">
<span className="mt-1 mr-2 text-xs">
<FormattedRelativeTime
value={Math.floor(
(new Date(release.created_at).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
<span className="text-lg font-bold">{release.name}</span>
{isLatest && (
<span className="ml-2 -mt-1">
<Badge badgeType="primary">
{intl.formatMessage(messages.latestversion)}
</Badge>
<div className="flex w-full flex-grow items-center justify-center space-x-2 truncate sm:justify-start">
<span className="truncate text-lg font-bold">
<span className="mr-2 whitespace-nowrap text-xs font-normal">
<FormattedRelativeTime
value={Math.floor(
(new Date(release.created_at).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
{release.name}
</span>
{isLatest && (
<Badge badgeType="success">
{intl.formatMessage(messages.latestversion)}
</Badge>
)}
{release.name.includes(currentVersion) && (
<span className="ml-2 -mt-1">
<Badge badgeType="success">
{intl.formatMessage(messages.currentversion)}
</Badge>
</span>
<Badge badgeType="primary">
{intl.formatMessage(messages.currentversion)}
</Badge>
)}
</div>
<div className="flex-1 text-center sm:text-right">
<Button buttonType="primary" onClick={() => setModalOpen(true)}>
<DocumentTextIcon />
<span>{intl.formatMessage(messages.viewchangelog)}</span>
</Button>
</div>
<Button buttonType="primary" onClick={() => setModalOpen(true)}>
<DocumentTextIcon />
<span>{intl.formatMessage(messages.viewchangelog)}</span>
</Button>
</div>
);
};
@@ -143,31 +139,10 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
return (
<div>
<h3 className="heading">{intl.formatMessage(messages.releases)}</h3>
<div className="section">
{currentVersion.startsWith('develop-') && (
<Alert
title={intl.formatMessage(messages.runningDevelopMessage, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
GithubLink: function GithubLink(msg) {
return (
<a
href="https://github.com/Fallenbagel/jellyseerr"
target="_blank"
rel="noreferrer"
className="text-yellow-100 underline transition duration-300 hover:text-white"
>
{msg}
</a>
);
},
})}
/>
)}
{data?.map((release, index) => {
<div className="section space-y-3">
{data.map((release, index) => {
return (
<div key={`release-${release.id}`} className="mb-2">
<div key={`release-${release.id}`}>
<Release
release={release}
currentVersion={currentVersion}

View File

@@ -8,6 +8,7 @@ import {
} from '../../../../server/interfaces/api/settingsInterfaces';
import globalMessages from '../../../i18n/globalMessages';
import Error from '../../../pages/_error';
import Alert from '../../Common/Alert';
import Badge from '../../Common/Badge';
import List from '../../Common/List';
import LoadingSpinner from '../../Common/LoadingSpinner';
@@ -16,14 +17,15 @@ import Releases from './Releases';
const messages = defineMessages({
about: 'About',
overseerrinformation: 'Jellyseerr Information',
overseerrinformation: 'About Overseerr',
version: 'Version',
totalmedia: 'Total Media',
totalrequests: 'Total Requests',
gettingsupport: 'Getting Support',
githubdiscussions: 'GitHub Discussions',
timezone: 'Time Zone',
supportoverseerr: 'Support Jellyseerr',
appDataPath: 'Data Directory',
supportoverseerr: 'Support Overseerr',
helppaycoffee: 'Help Pay for Coffee',
documentation: 'Documentation',
preferredmethod: 'Preferred',
@@ -31,6 +33,8 @@ const messages = defineMessages({
uptodate: 'Up to Date',
betawarning:
'This is BETA software. Features may be broken and/or unstable. Please report any issues on GitHub!',
runningDevelop:
'You are running the <code>develop</code> branch of Overseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.',
});
const SettingsAbout: React.FC = () => {
@@ -57,19 +61,19 @@ const SettingsAbout: React.FC = () => {
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="p-4 mt-6 bg-indigo-700 rounded-md">
<div className="mt-6 rounded-md bg-indigo-700 p-4">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className="w-5 h-5 text-white" />
<InformationCircleIcon className="h-5 w-5 text-white" />
</div>
<div className="flex-1 ml-3 md:flex md:justify-between">
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm leading-5 text-white">
{intl.formatMessage(messages.betawarning)}
</p>
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
<a
href="https://github.com/Fallenbagel/jellyseerr"
className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
href="http://github.com/fallenbagel/jellyseerr"
className="whitespace-nowrap font-medium text-indigo-100 transition duration-150 ease-in-out hover:text-white"
target="_blank"
rel="noreferrer"
>
@@ -81,22 +85,58 @@ const SettingsAbout: React.FC = () => {
</div>
<div className="section">
<List title={intl.formatMessage(messages.overseerrinformation)}>
{data.version.startsWith('develop-') && (
<Alert
title={intl.formatMessage(messages.runningDevelop, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
})}
/>
)}
<List.Item
title={intl.formatMessage(messages.version)}
className="truncate"
className="flex flex-row items-center truncate"
>
<code>{data.version.replace('develop-', '')}</code>
{status?.updateAvailable ? (
<Badge badgeType="warning" className="ml-2">
{intl.formatMessage(messages.outofdate)}
</Badge>
) : (
status?.commitTag !== 'local' && (
<Badge badgeType="success" className="ml-2">
{intl.formatMessage(messages.uptodate)}
</Badge>
)
)}
<code className="truncate">
{data.version.replace('develop-', '')}
</code>
{status?.commitTag !== 'local' &&
(status?.updateAvailable ? (
<a
href={
data.version.startsWith('develop-')
? `https://github.com/fallenbagel/jellyseerr/compare/${status.commitTag}...develop`
: 'https://github.com/fallenbagel/jellyseerr/releases'
}
target="_blank"
rel="noopener noreferrer"
>
<Badge
badgeType="warning"
className="ml-2 !cursor-pointer transition hover:bg-yellow-400"
>
{intl.formatMessage(messages.outofdate)}
</Badge>
</a>
) : (
<a
href={
data.version.startsWith('develop-')
? 'https://github.com/fallenbagel/jellyseerr/commits/develop'
: 'https://github.com/fallenbagel/jellyseerr/releases'
}
target="_blank"
rel="noopener noreferrer"
>
<Badge
badgeType="success"
className="ml-2 !cursor-pointer transition hover:bg-green-400"
>
{intl.formatMessage(messages.uptodate)}
</Badge>
</a>
))}
</List.Item>
<List.Item title={intl.formatMessage(messages.totalmedia)}>
{intl.formatNumber(data.totalMediaItems)}
@@ -104,6 +144,9 @@ const SettingsAbout: React.FC = () => {
<List.Item title={intl.formatMessage(messages.totalrequests)}>
{intl.formatNumber(data.totalRequests)}
</List.Item>
<List.Item title={intl.formatMessage(messages.appDataPath)}>
<code>{data.appDataPath}</code>
</List.Item>
{data.tz && (
<List.Item title={intl.formatMessage(messages.timezone)}>
<code>{data.tz}</code>
@@ -115,32 +158,32 @@ const SettingsAbout: React.FC = () => {
<List title={intl.formatMessage(messages.gettingsupport)}>
<List.Item title={intl.formatMessage(messages.documentation)}>
<a
href="https://github.com/Fallenbagel/jellyseerr#readme"
href="https://docs.overseerr.dev"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://github.com/Fallenbagel/jellyseerr#readme
https://docs.overseerr.dev
</a>
</List.Item>
<List.Item title={intl.formatMessage(messages.githubdiscussions)}>
<a
href="https://github.com/Fallenbagel/jellyseerr/discussions"
href="https://github.com/fallenbagel/jellyseerr/discussions"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://github.com/Fallenbagel/jellyseerr/discussions
https://github.com/fallenbagel/jellyseerr/discussions
</a>
</List.Item>
<List.Item title="Discord">
<a
href="https://discord.gg/XDyAd3AuUV"
href="https://discord.gg/ckbvBtDJgC"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://discord.gg/XDyAd3AuUV
https://discord.gg/ckbvBtDJgC
</a>
</List.Item>
</List>
@@ -151,17 +194,27 @@ const SettingsAbout: React.FC = () => {
title={`${intl.formatMessage(messages.helppaycoffee)} ☕️`}
>
<a
href="https://www.buymeacoffee.com/fallen.bagel"
href="https://github.com/sponsors/sct"
target="_blank"
rel="noreferrer"
className="text-indigo-500 hover:underline"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://www.buymeacoffee.com/fallen.bagel
https://github.com/sponsors/sct
</a>
<Badge className="ml-2">
{intl.formatMessage(messages.preferredmethod)}
</Badge>
</List.Item>
<List.Item title="">
<a
href="https://patreon.com/overseerr"
target="_blank"
rel="noreferrer"
className="text-indigo-500 transition duration-300 hover:underline"
>
https://patreon.com/overseerr
</a>
</List.Item>
</List>
</div>
<div className="section">

View File

@@ -1,8 +1,13 @@
import { SaveIcon } from '@heroicons/react/outline';
import axios from 'axios';
import { Field, Formik } from 'formik';
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import type { JellyfinSettings } from '../../../server/lib/settings';
import * as Yup from 'yup';
import { JellyfinSettings } from '../../../server/lib/settings';
import globalMessages from '../../i18n/globalMessages';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner';
@@ -11,18 +16,26 @@ import LibraryItem from './LibraryItem';
const messages = defineMessages({
jellyfinsettings: 'Jellyfin Settings',
jellyfinsettingsDescription:
'Configure the settings for your Jellyfin server. Jellyseerr scans your Jellyfin libraries to see what content is available.',
'Configure the settings for your Jellyfin server. Jellyfin scans your Jellyfin libraries to see what content is available.',
timeout: 'Timeout',
save: 'Save Changes',
saving: 'Saving…',
jellyfinlibraries: 'Jellyfin Libraries',
jellyfinlibrariesDescription:
'The libraries Jellyseerr scans for titles. Click the button below if no libraries are listed.',
'The libraries Jellyfin scans for titles. Click the button below if no libraries are listed.',
jellyfinSettingsFailure:
'Something went wrong while saving Jellyfin settings.',
jellyfinSettingsSuccess: 'Jellyfin settings saved successfully!',
jellyfinSettings: 'Jellyfin Settings',
jellyfinSettingsDescription:
'Optionally configure an external player endpoint for your jellyfin server that is different to the internal URL used during setup',
externalUrl: 'External URL',
validationUrl: 'You must provide a valid URL',
syncing: 'Syncing',
syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan',
manualscanDescriptionJellyfin:
"Normally, this will only be run once every 24 hours. Jellyseerr will check your Jellyfin server's recently added more aggressively. If this is your first time configuring Jellyfin, a one-time full manual library scan is recommended!",
"Normally, this will only be run once every 24 hours. Jellyfin will check your Jellyfin server's recently added more aggressively. If this is your first time configuring Jellyfin, a one-time full manual library scan is recommended!",
notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}',
@@ -44,24 +57,36 @@ interface SyncStatus {
libraries: Library[];
}
interface SettingsJellyfinProps {
showAdvancedSettings?: boolean;
onComplete?: () => void;
}
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
onComplete,
showAdvancedSettings,
}) => {
const [isSyncing, setIsSyncing] = useState(false);
const {
data: data,
error: error,
revalidate: revalidate,
data,
error,
mutate: revalidate,
} = useSWR<JellyfinSettings>('/api/v1/settings/jellyfin');
const { data: dataSync, revalidate: revalidateSync } = useSWR<SyncStatus>(
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
'/api/v1/settings/jellyfin/sync',
{
refreshInterval: 1000,
}
);
const intl = useIntl();
const { addToast } = useToasts();
const JellyfinSettingsSchema = Yup.object().shape({
jellyfinExternalUrl: Yup.string().matches(
/^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/,
intl.formatMessage(messages.validationUrl)
),
});
const activeLibraries =
data?.libraries
@@ -145,7 +170,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
<div className="section">
<Button onClick={() => syncLibraries()} disabled={isSyncing}>
<svg
className={`${isSyncing ? 'animate-spin' : ''} w-5 h-5 mr-1`}
className={`${isSyncing ? 'animate-spin' : ''} mr-1 h-5 w-5`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
@@ -160,7 +185,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
? intl.formatMessage(messages.syncing)
: intl.formatMessage(messages.syncJellyfin)}
</Button>
<ul className="grid grid-cols-1 gap-5 mt-6 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
<ul className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{data?.libraries.map((library) => (
<LibraryItem
name={library.name}
@@ -180,11 +205,11 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
</p>
</div>
<div className="section">
<div className="p-4 bg-gray-800 rounded-md">
<div className="relative w-full h-8 mb-6 overflow-hidden bg-gray-600 rounded-full">
<div className="rounded-md bg-gray-800 p-4">
<div className="relative mb-6 h-8 w-full overflow-hidden rounded-full bg-gray-600">
{dataSync?.running && (
<div
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
className="h-8 bg-indigo-600 transition-all duration-200 ease-in-out"
style={{
width: `${Math.round(
(dataSync.progress / dataSync.total) * 100
@@ -192,7 +217,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
}}
/>
)}
<div className="absolute inset-0 flex items-center justify-center w-full h-8 text-sm">
<div className="absolute inset-0 flex h-8 w-full items-center justify-center text-sm">
<span>
{dataSync?.running
? `${dataSync.progress} of ${dataSync.total}`
@@ -200,11 +225,11 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
</span>
</div>
</div>
<div className="flex flex-col w-full sm:flex-row">
<div className="flex w-full flex-col sm:flex-row">
{dataSync?.running && (
<>
{dataSync.currentLibrary && (
<div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2">
<div className="mb-2 mr-0 flex items-center sm:mb-0 sm:mr-2">
<Badge>
<FormattedMessage
{...messages.currentlibrary}
@@ -236,7 +261,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
{!dataSync?.running && (
<Button buttonType="warning" onClick={() => startScan()}>
<svg
className="w-5 h-5 mr-1"
className="mr-1 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -256,7 +281,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
{dataSync?.running && (
<Button buttonType="danger" onClick={() => cancelScan()}>
<svg
className="w-5 h-5 mr-1"
className="mr-1 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -276,6 +301,89 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
</div>
</div>
</div>
{showAdvancedSettings && (
<>
<div className="mt-10 mb-6">
<h3 className="heading">
{intl.formatMessage(messages.jellyfinSettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.jellyfinSettingsDescription)}
</p>
</div>
<Formik
initialValues={{
jellyfinExternalUrl: data?.externalHostname || '',
}}
validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/jellyfin', {
externalHostname: values.jellyfinExternalUrl,
} as JellyfinSettings);
addToast(intl.formatMessage(messages.jellyfinSettingsSuccess), {
autoDismiss: true,
appearance: 'success',
});
} catch (e) {
addToast(intl.formatMessage(messages.jellyfinSettingsFailure), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidate();
}
}}
>
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
return (
<form className="section" onSubmit={handleSubmit}>
<div className="form-row">
<label htmlFor="jellyfinExternalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="jellyfinExternalUrl"
name="jellyfinExternalUrl"
/>
</div>
{errors.jellyfinExternalUrl &&
touched.jellyfinExternalUrl && (
<div className="error">
{errors.jellyfinExternalUrl}
</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</form>
);
}}
</Formik>
</>
)}
</>
);
};

View File

@@ -1,6 +1,7 @@
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline';
import { PencilIcon } from '@heroicons/react/solid';
import axios from 'axios';
import React from 'react';
import React, { useState } from 'react';
import {
defineMessages,
FormattedRelativeTime,
@@ -10,20 +11,23 @@ import {
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces';
import { JobId } from '../../../../server/lib/settings';
import Spinner from '../../../assets/spinner.svg';
import globalMessages from '../../../i18n/globalMessages';
import { formatBytes } from '../../../utils/numberHelpers';
import Badge from '../../Common/Badge';
import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Modal from '../../Common/Modal';
import PageTitle from '../../Common/PageTitle';
import Table from '../../Common/Table';
import Transition from '../../Transition';
const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
jobsandcache: 'Jobs & Cache',
jobs: 'Jobs',
jobsDescription:
'Jellyseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.',
'Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered below. Manually running a job will not alter its schedule.',
jobname: 'Job Name',
jobtype: 'Type',
nextexecution: 'Next Execution',
@@ -35,7 +39,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
command: 'Command',
cache: 'Cache',
cacheDescription:
'Jellyseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.',
'Overseerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.',
cacheflushed: '{cachename} cache flushed.',
cachename: 'Cache Name',
cachehits: 'Hits',
@@ -53,12 +57,21 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'sonarr-scan': 'Sonarr Scan',
'download-sync': 'Download Sync',
'download-sync-reset': 'Download Sync Reset',
editJobSchedule: 'Modify Job',
jobScheduleEditSaved: 'Job edited successfully!',
jobScheduleEditFailed: 'Something went wrong while saving the job.',
editJobSchedulePrompt: 'Frequency',
editJobScheduleSelectorHours:
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes:
'Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}',
});
interface Job {
id: string;
id: JobId;
name: string;
type: 'process' | 'command';
interval: 'short' | 'long' | 'fixed';
nextExecutionTime: string;
running: boolean;
}
@@ -66,16 +79,30 @@ interface Job {
const SettingsJobs: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR<Job[]>('/api/v1/settings/jobs', {
const {
data,
error,
mutate: revalidate,
} = useSWR<Job[]>('/api/v1/settings/jobs', {
refreshInterval: 5000,
});
const { data: cacheData, revalidate: cacheRevalidate } = useSWR<CacheItem[]>(
const { data: cacheData, mutate: cacheRevalidate } = useSWR<CacheItem[]>(
'/api/v1/settings/cache',
{
refreshInterval: 10000,
}
);
const [jobEditModal, setJobEditModal] = useState<{
isOpen: boolean;
job?: Job;
}>({
isOpen: false,
});
const [isSaving, setIsSaving] = useState(false);
const [jobScheduleMinutes, setJobScheduleMinutes] = useState(5);
const [jobScheduleHours, setJobScheduleHours] = useState(1);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -120,6 +147,42 @@ const SettingsJobs: React.FC = () => {
cacheRevalidate();
};
const scheduleJob = async () => {
const jobScheduleCron = ['0', '0', '*', '*', '*', '*'];
try {
if (jobEditModal.job?.interval === 'short') {
jobScheduleCron[1] = `*/${jobScheduleMinutes}`;
} else if (jobEditModal.job?.interval === 'long') {
jobScheduleCron[2] = `*/${jobScheduleHours}`;
} else {
// jobs with interval: fixed should not be editable
throw new Error();
}
setIsSaving(true);
await axios.post(
`/api/v1/settings/jobs/${jobEditModal.job?.id}/schedule`,
{
schedule: jobScheduleCron.join(' '),
}
);
addToast(intl.formatMessage(messages.jobScheduleEditSaved), {
appearance: 'success',
autoDismiss: true,
});
setJobEditModal({ isOpen: false });
revalidate();
} catch (e) {
addToast(intl.formatMessage(messages.jobScheduleEditFailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsSaving(false);
}
};
return (
<>
<PageTitle
@@ -128,6 +191,82 @@ const SettingsJobs: React.FC = () => {
intl.formatMessage(globalMessages.settings),
]}
/>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={jobEditModal.isOpen}
>
<Modal
title={intl.formatMessage(messages.editJobSchedule)}
okText={
isSaving
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)
}
iconSvg={<PencilIcon />}
onCancel={() => setJobEditModal({ isOpen: false })}
okDisabled={isSaving}
onOk={() => scheduleJob()}
>
<div className="section">
<form>
<div className="form-row pb-6">
<label htmlFor="jobSchedule" className="text-label">
{intl.formatMessage(messages.editJobSchedulePrompt)}
</label>
<div className="form-input-area">
{jobEditModal.job?.interval === 'short' ? (
<select
name="jobScheduleMinutes"
className="inline"
value={jobScheduleMinutes}
onChange={(e) =>
setJobScheduleMinutes(Number(e.target.value))
}
>
{[5, 10, 15, 20, 30, 60].map((v) => (
<option value={v} key={`jobScheduleMinutes-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorMinutes,
{
jobScheduleMinutes: v,
}
)}
</option>
))}
</select>
) : (
<select
name="jobScheduleHours"
className="inline"
value={jobScheduleHours}
onChange={(e) =>
setJobScheduleHours(Number(e.target.value))
}
>
{[1, 2, 3, 4, 6, 8, 12, 24, 48, 72].map((v) => (
<option value={v} key={`jobScheduleHours-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorHours,
{
jobScheduleHours: v,
}
)}
</option>
))}
</select>
)}
</div>
</div>
</form>
</div>
</Modal>
</Transition>
<div className="mb-6">
<h3 className="heading">{intl.formatMessage(messages.jobs)}</h3>
<p className="description">
@@ -154,7 +293,7 @@ const SettingsJobs: React.FC = () => {
messages[job.id] ?? messages.unknownJob
)}
</span>
{job.running && <Spinner className="w-5 h-5 ml-2" />}
{job.running && <Spinner className="ml-2 h-5 w-5" />}
</div>
</Table.TD>
<Table.TD>
@@ -181,6 +320,18 @@ const SettingsJobs: React.FC = () => {
</div>
</Table.TD>
<Table.TD alignText="right">
{job.interval !== 'fixed' && (
<Button
className="mr-2"
buttonType="warning"
onClick={() =>
setJobEditModal({ isOpen: true, job: job })
}
>
<PencilIcon />
{intl.formatMessage(globalMessages.edit)}
</Button>
)}
{job.running ? (
<Button buttonType="danger" onClick={() => cancelJob(job)}>
<StopIcon />
@@ -188,7 +339,7 @@ const SettingsJobs: React.FC = () => {
</Button>
) : (
<Button buttonType="primary" onClick={() => runJob(job)}>
<PlayIcon className="w-5 h-5 mr-1" />
<PlayIcon className="mr-1 h-5 w-5" />
<span>{intl.formatMessage(messages.runnow)}</span>
</Button>
)}

View File

@@ -31,7 +31,7 @@ import Transition from '../../Transition';
const messages = defineMessages({
logs: 'Logs',
logsDescription:
'You can also view these logs directly via <code>stdout</code>, or in <code>{configDir}/logs/jellyseerr.log</code>.',
'You can also view these logs directly via <code>stdout</code>, or in <code>{appDataPath}/logs/overseerr.log</code>.',
time: 'Timestamp',
level: 'Severity',
label: 'Label',
@@ -158,7 +158,7 @@ const SettingsLogs: React.FC = () => {
{intl.formatMessage(messages.time)}
</div>
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<div className="flex items-center max-w-lg">
<div className="flex max-w-lg items-center">
{intl.formatDate(activeLog.timestamp, {
year: 'numeric',
month: 'short',
@@ -175,7 +175,7 @@ const SettingsLogs: React.FC = () => {
{intl.formatMessage(messages.level)}
</div>
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<div className="flex items-center max-w-lg">
<div className="flex max-w-lg items-center">
<Badge
badgeType={
activeLog.level === 'error'
@@ -197,7 +197,7 @@ const SettingsLogs: React.FC = () => {
{intl.formatMessage(messages.label)}
</div>
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<div className="flex items-center max-w-lg">
<div className="flex max-w-lg items-center">
{activeLog.label}
</div>
</div>
@@ -207,7 +207,7 @@ const SettingsLogs: React.FC = () => {
{intl.formatMessage(messages.message)}
</div>
<div className="col-span-2 mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<div className="flex items-center max-w-lg">
<div className="flex max-w-lg items-center">
{activeLog.message}
</div>
</div>
@@ -218,7 +218,7 @@ const SettingsLogs: React.FC = () => {
{intl.formatMessage(messages.extraData)}
</div>
<div className="col-span-2 mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<code className="block w-full px-6 py-4 overflow-auto whitespace-pre bg-gray-800 ring-1 ring-gray-700 max-h-64">
<code className="block max-h-64 w-full overflow-auto whitespace-pre bg-gray-800 px-6 py-4 ring-1 ring-gray-700">
{JSON.stringify(activeLog.data, null, ' ')}
</code>
</div>
@@ -235,13 +235,13 @@ const SettingsLogs: React.FC = () => {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
configDir: appData ? appData.appDataPath : '/app/config',
appDataPath: appData ? appData.appDataPath : '/app/config',
})}
</p>
<div className="flex flex-row flex-grow mt-2 sm:flex-grow-0 sm:justify-end">
<div className="flex flex-row justify-between flex-1 mb-2 sm:mb-0 sm:flex-none">
<div className="mt-2 flex flex-grow flex-row sm:flex-grow-0 sm:justify-end">
<div className="mb-2 flex flex-1 flex-row justify-between sm:mb-0 sm:flex-none">
<Button
className="flex-grow w-full mr-2"
className="mr-2 w-full flex-grow"
buttonType={refreshInterval ? 'default' : 'primary'}
onClick={() => toggleLogs()}
>
@@ -253,9 +253,9 @@ const SettingsLogs: React.FC = () => {
</span>
</Button>
</div>
<div className="flex flex-1 mb-2 sm:mb-0 sm:flex-none">
<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" />
<div className="mb-2 flex flex-1 sm:mb-0 sm:flex-none">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-6" />
</span>
<select
id="filter"
@@ -321,9 +321,11 @@ const SettingsLogs: React.FC = () => {
{row.level.toUpperCase()}
</Badge>
</Table.TD>
<Table.TD className="text-gray-300">{row.label}</Table.TD>
<Table.TD className="text-gray-300">
{row.label ?? ''}
</Table.TD>
<Table.TD className="text-gray-300">{row.message}</Table.TD>
<Table.TD className="flex flex-wrap items-center justify-end -m-1">
<Table.TD className="-m-1 flex flex-wrap items-center justify-end">
{row.data && (
<Button
buttonType="primary"
@@ -350,7 +352,7 @@ const SettingsLogs: React.FC = () => {
{data.results.length === 0 && (
<tr className="relative h-24 p-2 text-white">
<Table.TD colSpan={5} noPadding>
<div className="flex flex-col items-center justify-center w-screen p-6 md:w-full">
<div className="flex w-screen flex-col items-center justify-center p-6 md:w-full">
<span className="text-base">
{intl.formatMessage(globalMessages.noresults)}
</span>
@@ -372,7 +374,7 @@ const SettingsLogs: React.FC = () => {
<tr className="bg-gray-700">
<Table.TD colSpan={5} noPadding>
<nav
className="flex flex-col items-center w-screen px-6 py-3 space-x-4 space-y-3 sm:space-y-0 sm:flex-row md:w-full"
className="flex w-screen flex-col items-center space-x-4 space-y-3 px-6 py-3 sm:flex-row sm:space-y-0 md:w-full"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
@@ -393,7 +395,7 @@ const SettingsLogs: React.FC = () => {
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start md:justify-center">
<span className="items-center -mt-3 text-sm sm:-ml-4 md:ml-0 sm:mt-0">
<span className="-mt-3 items-center text-sm sm:-ml-4 sm:mt-0 md:ml-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
@@ -406,7 +408,7 @@ const SettingsLogs: React.FC = () => {
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="inline short"
className="short inline"
>
<option value="10">10</option>
<option value="25">25</option>
@@ -417,7 +419,7 @@ const SettingsLogs: React.FC = () => {
})}
</span>
</div>
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button
disabled={!hasPrevPage}
onClick={() =>

View File

@@ -29,7 +29,7 @@ const messages = defineMessages({
general: 'General',
generalsettings: 'General Settings',
generalsettingsDescription:
'Configure global and default settings for Jellyseerr.',
'Configure global and default settings for Overseerr.',
apikey: 'API Key',
applicationTitle: 'Application Title',
applicationurl: 'Application URL',
@@ -44,7 +44,7 @@ const messages = defineMessages({
hideAvailable: 'Hide Available Media',
csrfProtection: 'Enable CSRF Protection',
csrfProtectionTip:
'Set external API access to read-only (requires HTTPS, and Jellyseerr must be reloaded for changes to take effect)',
'Set external API access to read-only (requires HTTPS, and Overseerr must be reloaded for changes to take effect)',
csrfProtectionHoverTip:
'Do NOT enable this setting unless you understand what you are doing!',
cacheImages: 'Enable Image Caching',
@@ -52,7 +52,7 @@ const messages = defineMessages({
'Optimize and store all images locally (consumes a significant amount of disk space)',
trustProxy: 'Enable Proxy Support',
trustProxyTip:
'Allow Jellyseerr to correctly register client IP addresses behind a proxy (Jellyseerr must be reloaded for changes to take effect)',
'Allow Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)',
validationApplicationTitle: 'You must provide an application title',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
@@ -65,9 +65,11 @@ const SettingsMain: React.FC = () => {
const { user: currentUser, hasPermission: userHasPermission } = useUser();
const intl = useIntl();
const { setLocale } = useLocale();
const { data, error, revalidate } = useSWR<MainSettings>(
'/api/v1/settings/main'
);
const {
data,
error,
mutate: revalidate,
} = useSWR<MainSettings>('/api/v1/settings/main');
const { data: userData } = useSWR<UserSettingsGeneralResponse>(
currentUser ? `/api/v1/user/${currentUser.id}/settings/main` : null
);
@@ -81,12 +83,7 @@ const SettingsMain: React.FC = () => {
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => {
if (value?.substr(value.length - 1) === '/') {
return false;
}
return true;
}
(value) => !value || !value.endsWith('/')
),
});
@@ -194,7 +191,7 @@ const SettingsMain: React.FC = () => {
<label htmlFor="apiKey" className="text-label">
{intl.formatMessage(messages.apikey)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
type="text"
@@ -224,7 +221,7 @@ const SettingsMain: React.FC = () => {
<label htmlFor="applicationTitle" className="text-label">
{intl.formatMessage(messages.applicationTitle)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="applicationTitle"
@@ -241,7 +238,7 @@ const SettingsMain: React.FC = () => {
<label htmlFor="applicationUrl" className="text-label">
{intl.formatMessage(messages.applicationurl)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="applicationUrl"
@@ -262,7 +259,7 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.trustProxyTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="trustProxy"
@@ -285,7 +282,7 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.csrfProtectionTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="csrfProtection"
@@ -303,7 +300,7 @@ const SettingsMain: React.FC = () => {
<label htmlFor="locale" className="text-label">
{intl.formatMessage(messages.locale)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="locale" name="locale">
{(
@@ -330,7 +327,7 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.regionTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<RegionSelector
value={values.region ?? ''}
@@ -347,7 +344,7 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.originallanguageTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<LanguageSelector
setFieldValue={setFieldValue}
@@ -365,7 +362,7 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(globalMessages.experimental)}
</Badge>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="hideAvailable"
@@ -385,7 +382,7 @@ const SettingsMain: React.FC = () => {
{intl.formatMessage(messages.partialRequestsEnabled)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="partialRequestsEnabled"
@@ -401,7 +398,7 @@ const SettingsMain: React.FC = () => {
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -2,6 +2,7 @@ import { CloudIcon, LightningBoltIcon, MailIcon } from '@heroicons/react/solid';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import DiscordLogo from '../../assets/extlogos/discord.svg';
import GotifyLogo from '../../assets/extlogos/gotify.svg';
import LunaSeaLogo from '../../assets/extlogos/lunasea.svg';
import PushbulletLogo from '../../assets/extlogos/pushbullet.svg';
import PushoverLogo from '../../assets/extlogos/pushover.svg';
@@ -29,7 +30,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: intl.formatMessage(messages.email),
content: (
<span className="flex items-center">
<MailIcon className="h-4 mr-2" />
<MailIcon className="mr-2 h-4" />
{intl.formatMessage(messages.email)}
</span>
),
@@ -40,7 +41,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: intl.formatMessage(messages.webpush),
content: (
<span className="flex items-center">
<CloudIcon className="h-4 mr-2" />
<CloudIcon className="mr-2 h-4" />
{intl.formatMessage(messages.webpush)}
</span>
),
@@ -51,18 +52,29 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: 'Discord',
content: (
<span className="flex items-center">
<DiscordLogo className="h-4 mr-2" />
<DiscordLogo className="mr-2 h-4" />
Discord
</span>
),
route: '/settings/notifications/discord',
regex: /^\/settings\/notifications\/discord/,
},
{
text: 'Gotify',
content: (
<span className="flex items-center">
<GotifyLogo className="mr-2 h-4" />
Gotify
</span>
),
route: '/settings/notifications/gotify',
regex: /^\/settings\/notifications\/gotify/,
},
{
text: 'LunaSea',
content: (
<span className="flex items-center">
<LunaSeaLogo className="h-4 mr-2" />
<LunaSeaLogo className="mr-2 h-4" />
LunaSea
</span>
),
@@ -73,7 +85,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: 'Pushbullet',
content: (
<span className="flex items-center">
<PushbulletLogo className="h-4 mr-2" />
<PushbulletLogo className="mr-2 h-4" />
Pushbullet
</span>
),
@@ -84,7 +96,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: 'Pushover',
content: (
<span className="flex items-center">
<PushoverLogo className="h-4 mr-2" />
<PushoverLogo className="mr-2 h-4" />
Pushover
</span>
),
@@ -95,7 +107,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: 'Slack',
content: (
<span className="flex items-center">
<SlackLogo className="h-4 mr-2" />
<SlackLogo className="mr-2 h-4" />
Slack
</span>
),
@@ -106,7 +118,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: 'Telegram',
content: (
<span className="flex items-center">
<TelegramLogo className="h-4 mr-2" />
<TelegramLogo className="mr-2 h-4" />
Telegram
</span>
),
@@ -117,7 +129,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
text: intl.formatMessage(messages.webhook),
content: (
<span className="flex items-center">
<LightningBoltIcon className="h-4 mr-2" />
<LightningBoltIcon className="mr-2 h-4" />
{intl.formatMessage(messages.webhook)}
</span>
),

View File

@@ -9,20 +9,24 @@ import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import type { PlexDevice } from '../../../server/interfaces/api/plexInterfaces';
import type { PlexSettings } from '../../../server/lib/settings';
import type {
PlexSettings,
TautulliSettings,
} from '../../../server/lib/settings';
import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import SensitiveInput from '../Common/SensitiveInput';
import LibraryItem from './LibraryItem';
const messages = defineMessages({
plex: 'Plex',
plexsettings: 'Plex Settings',
plexsettingsDescription:
'Configure the settings for your Plex server. Jellyseerr scans your Plex libraries to determine content availability.',
'Configure the settings for your Plex server. Overseerr scans your Plex libraries to determine content availability.',
serverpreset: 'Server',
serverLocal: 'local',
serverRemote: 'remote',
@@ -43,12 +47,12 @@ const messages = defineMessages({
enablessl: 'Use SSL',
plexlibraries: 'Plex Libraries',
plexlibrariesDescription:
'The libraries Jellyseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.',
'The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.',
scanning: 'Syncing…',
scan: 'Sync Libraries',
manualscan: 'Manual Library Scan',
manualscanDescription:
"Normally, this will only be run once every 24 hours. Jellyseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
"Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",
notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}',
@@ -59,7 +63,20 @@ const messages = defineMessages({
webAppUrl: '<WebAppLink>Web App</WebAppLink> URL',
webAppUrlTip:
'Optionally direct users to the web app on your server instead of the "hosted" web app',
validationWebAppUrl: 'You must provide a valid Plex Web App URL',
tautulliSettings: 'Tautulli Settings',
tautulliSettingsDescription:
'Optionally configure the settings for your Tautulli server. Overseerr fetches watch history data for your Plex media from Tautulli.',
urlBase: 'URL Base',
tautulliApiKey: 'API Key',
externalUrl: 'External URL',
validationApiKey: 'You must provide an API key',
validationUrl: 'You must provide a valid URL',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
toastTautulliSettingsSuccess: 'Tautulli settings saved successfully!',
toastTautulliSettingsFailure:
'Something went wrong while saving Tautulli settings.',
});
interface Library {
@@ -96,10 +113,14 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
const [availableServers, setAvailableServers] = useState<PlexDevice[] | null>(
null
);
const { data, error, revalidate } = useSWR<PlexSettings>(
'/api/v1/settings/plex'
);
const { data: dataSync, revalidate: revalidateSync } = useSWR<SyncStatus>(
const {
data,
error,
mutate: revalidate,
} = useSWR<PlexSettings>('/api/v1/settings/plex');
const { data: dataTautulli, mutate: revalidateTautulli } =
useSWR<TautulliSettings>('/api/v1/settings/tautulli');
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
'/api/v1/settings/plex/sync',
{
refreshInterval: 1000,
@@ -107,12 +128,13 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
);
const intl = useIntl();
const { addToast, removeToast } = useToasts();
const PlexSettingsSchema = Yup.object().shape({
hostname: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number()
@@ -120,9 +142,66 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
.required(intl.formatMessage(messages.validationPortRequired)),
webAppUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationWebAppUrl)),
.url(intl.formatMessage(messages.validationUrl)),
});
const TautulliSettingsSchema = Yup.object().shape(
{
tautulliHostname: Yup.string()
.when(['tautulliPort', 'tautulliApiKey'], {
is: (value: unknown) => !!value,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationHostnameRequired)),
otherwise: Yup.string().nullable(),
})
.matches(
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
tautulliPort: Yup.number().when(['tautulliHostname', 'tautulliApiKey'], {
is: (value: unknown) => !!value,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationPortRequired))
.nullable()
.required(intl.formatMessage(messages.validationPortRequired)),
otherwise: Yup.number()
.typeError(intl.formatMessage(messages.validationPortRequired))
.nullable(),
}),
tautulliUrlBase: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
(value) => !value || !value.endsWith('/')
),
tautulliApiKey: Yup.string().when(['tautulliHostname', 'tautulliPort'], {
is: (value: unknown) => !!value,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationApiKey)),
otherwise: Yup.string().nullable(),
}),
tautulliExternalUrl: Yup.string()
.url(intl.formatMessage(messages.validationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
},
[
['tautulliHostname', 'tautulliPort'],
['tautulliHostname', 'tautulliApiKey'],
['tautulliPort', 'tautulliApiKey'],
]
);
const activeLibraries =
data?.libraries
.filter((library) => library.enabled)
@@ -245,7 +324,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
revalidate();
};
if (!data && !error) {
if ((!data || !dataTautulli) && !error) {
return <LoadingSpinner />;
}
return (
@@ -351,7 +430,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
<label htmlFor="preset" className="text-label">
{intl.formatMessage(messages.serverpreset)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<select
id="preset"
@@ -425,9 +504,9 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
{intl.formatMessage(messages.hostname)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.useSsl ? 'https://' : 'http://'}
</span>
<Field
@@ -448,7 +527,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="text"
inputMode="numeric"
@@ -465,7 +544,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
<label htmlFor="ssl" className="checkbox-label">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="useSsl"
@@ -498,7 +577,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
{intl.formatMessage(messages.webAppUrlTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
@@ -515,7 +594,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
@@ -558,7 +637,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
: intl.formatMessage(messages.scan)}
</span>
</Button>
<ul className="grid grid-cols-1 gap-5 mt-6 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
<ul className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{data?.libraries.map((library) => (
<LibraryItem
name={library.name}
@@ -576,11 +655,11 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</p>
</div>
<div className="section">
<div className="p-4 bg-gray-800 rounded-md">
<div className="relative w-full h-8 mb-6 overflow-hidden bg-gray-600 rounded-full">
<div className="rounded-md bg-gray-800 p-4">
<div className="relative mb-6 h-8 w-full overflow-hidden rounded-full bg-gray-600">
{dataSync?.running && (
<div
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
className="h-8 bg-indigo-600 transition-all duration-200 ease-in-out"
style={{
width: `${Math.round(
(dataSync.progress / dataSync.total) * 100
@@ -588,7 +667,7 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
}}
/>
)}
<div className="absolute inset-0 flex items-center justify-center w-full h-8 text-sm">
<div className="absolute inset-0 flex h-8 w-full items-center justify-center text-sm">
<span>
{dataSync?.running
? `${dataSync.progress} of ${dataSync.total}`
@@ -596,11 +675,11 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</span>
</div>
</div>
<div className="flex flex-col w-full sm:flex-row">
<div className="flex w-full flex-col sm:flex-row">
{dataSync?.running && (
<>
{dataSync.currentLibrary && (
<div className="flex items-center mb-2 mr-0 sm:mb-0 sm:mr-2">
<div className="mb-2 mr-0 flex items-center sm:mb-0 sm:mr-2">
<Badge>
{intl.formatMessage(messages.currentlibrary, {
name: dataSync.currentLibrary.name,
@@ -644,6 +723,209 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
</div>
</div>
</div>
{!onComplete && (
<>
<div className="mt-10 mb-6">
<h3 className="heading">
{intl.formatMessage(messages.tautulliSettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.tautulliSettingsDescription)}
</p>
</div>
<Formik
initialValues={{
tautulliHostname: dataTautulli?.hostname,
tautulliPort: dataTautulli?.port ?? 8181,
tautulliUseSsl: dataTautulli?.useSsl,
tautulliUrlBase: dataTautulli?.urlBase,
tautulliApiKey: dataTautulli?.apiKey,
tautulliExternalUrl: dataTautulli?.externalUrl,
}}
validationSchema={TautulliSettingsSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/tautulli', {
hostname: values.tautulliHostname,
port: Number(values.tautulliPort),
useSsl: values.tautulliUseSsl,
urlBase: values.tautulliUrlBase,
apiKey: values.tautulliApiKey,
externalUrl: values.tautulliExternalUrl,
} as TautulliSettings);
addToast(
intl.formatMessage(messages.toastTautulliSettingsSuccess),
{
autoDismiss: true,
appearance: 'success',
}
);
} catch (e) {
addToast(
intl.formatMessage(messages.toastTautulliSettingsFailure),
{
autoDismiss: true,
appearance: 'error',
}
);
} finally {
revalidateTautulli();
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
<div className="form-row">
<label htmlFor="tautulliHostname" className="text-label">
{intl.formatMessage(messages.hostname)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
{values.tautulliUseSsl ? 'https://' : 'http://'}
</span>
<Field
type="text"
inputMode="url"
id="tautulliHostname"
name="tautulliHostname"
className="rounded-r-only"
/>
</div>
{errors.tautulliHostname && touched.tautulliHostname && (
<div className="error">{errors.tautulliHostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tautulliPort" className="text-label">
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field
type="text"
inputMode="numeric"
id="tautulliPort"
name="tautulliPort"
className="short"
/>
{errors.tautulliPort && touched.tautulliPort && (
<div className="error">{errors.tautulliPort}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tautulliUseSsl" className="checkbox-label">
{intl.formatMessage(messages.enablessl)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tautulliUseSsl"
name="tautulliUseSsl"
onChange={() => {
setFieldValue(
'tautulliUseSsl',
!values.tautulliUseSsl
);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tautulliUrlBase" className="text-label">
{intl.formatMessage(messages.urlBase)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="tautulliUrlBase"
name="tautulliUrlBase"
/>
</div>
{errors.tautulliUrlBase && touched.tautulliUrlBase && (
<div className="error">{errors.tautulliUrlBase}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tautulliApiKey" className="text-label">
{intl.formatMessage(messages.tautulliApiKey)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
id="tautulliApiKey"
name="tautulliApiKey"
autoComplete="one-time-code"
/>
</div>
{errors.tautulliApiKey && touched.tautulliApiKey && (
<div className="error">{errors.tautulliApiKey}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tautulliExternalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="tautulliExternalUrl"
name="tautulliExternalUrl"
/>
</div>
{errors.tautulliExternalUrl &&
touched.tautulliExternalUrl && (
<div className="error">
{errors.tautulliExternalUrl}
</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</form>
);
}}
</Formik>
</>
)}
</>
);
};

View File

@@ -79,14 +79,14 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
const serviceUrl = externalUrl ?? internalUrl;
return (
<li className="col-span-1 bg-gray-800 rounded-lg shadow ring-1 ring-gray-500">
<div className="flex items-center justify-between w-full p-6 space-x-6">
<li className="col-span-1 rounded-lg bg-gray-800 shadow ring-1 ring-gray-500">
<div className="flex w-full items-center justify-between space-x-6 p-6">
<div className="flex-1 truncate">
<div className="flex items-center mb-2 space-x-2">
<h3 className="font-medium leading-5 text-white truncate">
<div className="mb-2 flex items-center space-x-2">
<h3 className="truncate font-medium leading-5 text-white">
<a
href={serviceUrl}
className="transition duration-300 hover:underline hover:text-white"
className="transition duration-300 hover:text-white hover:underline"
>
{name}
</a>
@@ -108,18 +108,18 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</Badge>
)}
</div>
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.address)}
</span>
<a
href={internalUrl}
className="transition duration-300 hover:underline hover:text-white"
className="transition duration-300 hover:text-white hover:underline"
>
{internalUrl}
</a>
</p>
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<p className="mt-1 truncate text-sm leading-5 text-gray-300">
<span className="mr-2 font-bold">
{intl.formatMessage(messages.activeProfile)}
</span>
@@ -128,29 +128,29 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</div>
<a href={serviceUrl} className="opacity-50 hover:opacity-100">
{isSonarr ? (
<SonarrLogo className="flex-shrink-0 w-10 h-10" />
<SonarrLogo className="h-10 w-10 flex-shrink-0" />
) : (
<RadarrLogo className="flex-shrink-0 w-10 h-10" />
<RadarrLogo className="h-10 w-10 flex-shrink-0" />
)}
</a>
</div>
<div className="border-t border-gray-500">
<div className="flex -mt-px">
<div className="flex flex-1 w-0 border-r border-gray-500">
<div className="-mt-px flex">
<div className="flex w-0 flex-1 border-r border-gray-500">
<button
onClick={() => onEdit()}
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 -mr-px text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<PencilIcon className="w-5 h-5 mr-2" />
<PencilIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</button>
</div>
<div className="flex flex-1 w-0 -ml-px">
<div className="-ml-px flex w-0 flex-1">
<button
onClick={() => onDelete()}
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
>
<TrashIcon className="w-5 h-5 mr-2" />
<TrashIcon className="mr-2 h-5 w-5" />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</button>
</div>
@@ -165,12 +165,12 @@ const SettingsServices: React.FC = () => {
const {
data: radarrData,
error: radarrError,
revalidate: revalidateRadarr,
mutate: revalidateRadarr,
} = useSWR<RadarrSettings[]>('/api/v1/settings/radarr');
const {
data: sonarrData,
error: sonarrError,
revalidate: revalidateSonarr,
mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean;
@@ -292,7 +292,7 @@ const SettingsServices: React.FC = () => {
serverType: 'Radarr',
strong: function strong(msg) {
return (
<strong className="font-semibold text-yellow-100">
<strong className="font-semibold text-white">
{msg}
</strong>
);
@@ -334,8 +334,8 @@ const SettingsServices: React.FC = () => {
}
/>
))}
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-44">
<div className="flex items-center justify-center w-full h-full">
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
className="mt-3 mb-3"
@@ -382,7 +382,7 @@ const SettingsServices: React.FC = () => {
serverType: 'Sonarr',
strong: function strong(msg) {
return (
<strong className="font-semibold text-yellow-100">
<strong className="font-semibold text-white">
{msg}
</strong>
);
@@ -425,8 +425,8 @@ const SettingsServices: React.FC = () => {
}
/>
))}
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-44">
<div className="flex items-center justify-center w-full h-full">
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>

View File

@@ -5,7 +5,9 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import { MediaServerType } from '../../../../server/constants/server';
import type { MainSettings } from '../../../../server/lib/settings';
import useSettings from '../../../hooks/useSettings';
import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner';
@@ -22,8 +24,9 @@ const messages = defineMessages({
localLogin: 'Enable Local Sign-In',
localLoginTip:
'Allow users to sign in using their email address and password, instead of Plex OAuth',
newPlexLogin: 'Enable New Plex Sign-In',
newPlexLoginTip: 'Allow Plex users to sign in without first being imported',
newPlexLogin: 'Enable New {mediaServerName} Sign-In',
newPlexLoginTip:
'Allow {mediaServerName} users to sign in without first being imported',
movieRequestLimitLabel: 'Global Movie Request Limit',
tvRequestLimitLabel: 'Global Series Request Limit',
defaultPermissions: 'Default Permissions',
@@ -33,9 +36,12 @@ const messages = defineMessages({
const SettingsUsers: React.FC = () => {
const { addToast } = useToasts();
const intl = useIntl();
const { data, error, revalidate } = useSWR<MainSettings>(
'/api/v1/settings/main'
);
const {
data,
error,
mutate: revalidate,
} = useSWR<MainSettings>('/api/v1/settings/main');
const settings = useSettings();
if (!data && !error) {
return <LoadingSpinner />;
@@ -110,7 +116,7 @@ const SettingsUsers: React.FC = () => {
{intl.formatMessage(messages.localLoginTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="localLogin"
@@ -123,12 +129,24 @@ const SettingsUsers: React.FC = () => {
</div>
<div className="form-row">
<label htmlFor="newPlexLogin" className="checkbox-label">
{intl.formatMessage(messages.newPlexLogin)}
{intl.formatMessage(messages.newPlexLogin, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
<span className="label-tip">
{intl.formatMessage(messages.newPlexLoginTip)}
{intl.formatMessage(messages.newPlexLoginTip, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="newPlexLogin"
@@ -143,7 +161,7 @@ const SettingsUsers: React.FC = () => {
<label htmlFor="applicationTitle" className="text-label">
{intl.formatMessage(messages.movieRequestLimitLabel)}
</label>
<div className="form-input">
<div className="form-input-area">
<QuotaSelector
onChange={setFieldValue}
dayFieldName="movieQuotaDays"
@@ -158,7 +176,7 @@ const SettingsUsers: React.FC = () => {
<label htmlFor="applicationTitle" className="text-label">
{intl.formatMessage(messages.tvRequestLimitLabel)}
</label>
<div className="form-input">
<div className="form-input-area">
<QuotaSelector
onChange={setFieldValue}
dayFieldName="tvQuotaDays"
@@ -181,7 +199,7 @@ const SettingsUsers: React.FC = () => {
{intl.formatMessage(messages.defaultPermissionsTip)}
</span>
</span>
<div className="form-input">
<div className="form-input-area">
<div className="max-w-lg">
<PermissionEdit
currentPermission={values.defaultPermissions}
@@ -195,7 +213,7 @@ const SettingsUsers: React.FC = () => {
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

View File

@@ -1,10 +1,9 @@
import { PencilIcon, PlusIcon } from '@heroicons/react/solid';
import axios from 'axios';
import { Field, Formik } from 'formik';
import dynamic from 'next/dynamic';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import Select, { OnChangeValue } from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import type { SonarrSettings } from '../../../../server/lib/settings';
@@ -14,19 +13,17 @@ import SensitiveInput from '../../Common/SensitiveInput';
import Transition from '../../Transition';
type OptionType = {
value: string;
value: number;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
createsonarr: 'Add New Sonarr Server',
create4ksonarr: 'Add New 4K Sonarr Server',
editsonarr: 'Edit Sonarr Server',
edit4ksonarr: 'Edit 4K Sonarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname or IP address',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
@@ -92,6 +89,7 @@ interface TestResponse {
id: number;
label: string;
}[];
urlBase?: string;
}
interface SonarrModalProps {
@@ -123,7 +121,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number()
@@ -348,6 +346,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
port: values.port,
useSsl: values.ssl,
});
if (!values.baseUrl || values.baseUrl === '/') {
setFieldValue('baseUrl', testResponse.urlBase);
}
}
}}
secondaryDisabled={
@@ -381,7 +382,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
: messages.defaultserver
)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="isDefault" name="isDefault" />
</div>
</div>
@@ -389,7 +390,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="is4k" className="checkbox-label">
{intl.formatMessage(messages.server4k)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field type="checkbox" id="is4k" name="is4k" />
</div>
</div>
@@ -398,7 +399,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.servername)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="name"
@@ -420,7 +421,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.hostname)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<span className="protocol">
{values.ssl ? 'https://' : 'http://'}
@@ -447,7 +448,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<Field
id="port"
name="port"
@@ -468,7 +469,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="ssl" className="checkbox-label">
{intl.formatMessage(messages.ssl)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="ssl"
@@ -485,7 +486,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.apiKey)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
@@ -507,7 +508,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="baseUrl" className="text-label">
{intl.formatMessage(messages.baseUrl)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="baseUrl"
@@ -530,7 +531,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -568,7 +569,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -607,7 +608,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
{intl.formatMessage(messages.languageprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -651,8 +652,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input">
<Select
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
@@ -676,25 +677,29 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
value={
isTesting
? []
: values.tags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})
: (values.tags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[])
}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
onChange={(value: OnChangeValue<OptionType, true>) => {
setFieldValue(
'tags',
value?.map((option) => option.value)
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
@@ -707,7 +712,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="activeAnimeProfileId" className="text-label">
{intl.formatMessage(messages.animequalityprofile)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -747,7 +752,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="activeAnimeRootFolder" className="text-label">
{intl.formatMessage(messages.animerootfolder)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -786,7 +791,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
>
{intl.formatMessage(messages.animelanguageprofile)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
@@ -830,8 +835,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.animeTags)}
</label>
<div className="form-input">
<Select
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
@@ -855,25 +860,29 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
value={
isTesting
? []
: values.animeTags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})
: (values.animeTags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[])
}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
onChange={(value) => {
setFieldValue(
'animeTags',
value?.map((option) => option.value)
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
@@ -889,7 +898,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
>
{intl.formatMessage(messages.seasonfolders)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="enableSeasonFolders"
@@ -901,7 +910,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input">
<div className="form-input-area">
<div className="form-input-field">
<Field
id="externalUrl"
@@ -919,7 +928,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="syncEnabled" className="checkbox-label">
{intl.formatMessage(messages.syncEnabled)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="syncEnabled"
@@ -931,7 +940,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
<label htmlFor="enableSearch" className="checkbox-label">
{intl.formatMessage(messages.enableSearch)}
</label>
<div className="form-input">
<div className="form-input-area">
<Field
type="checkbox"
id="enableSearch"

View File

@@ -5,7 +5,7 @@ import { useUser } from '../../hooks/useUser';
import PlexLoginButton from '../PlexLoginButton';
const messages = defineMessages({
welcome: 'Welcome to Jellyseerr',
welcome: 'Welcome to Overseerr',
signinMessage: 'Get started by signing in with your Plex account',
});
@@ -45,10 +45,10 @@ const LoginWithPlex: React.FC<LoginWithPlexProps> = ({ onComplete }) => {
return (
<form>
<div className="flex justify-center mb-2 text-xl font-bold">
<div className="mb-2 flex justify-center text-xl font-bold">
{intl.formatMessage(messages.welcome)}
</div>
<div className="flex justify-center pb-6 mb-2 text-sm">
<div className="mb-2 flex justify-center pb-6 text-sm">
{intl.formatMessage(messages.signinMessage)}
</div>
<div className="flex items-center justify-center">

View File

@@ -1,26 +1,26 @@
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { MediaServerType } from '../../../server/constants/server';
import { useUser } from '../../hooks/useUser';
import Accordion from '../Common/Accordion';
import JellyfinLogin from '../Login/JellyfinLogin';
import PlexLoginButton from '../PlexLoginButton';
import JellyfinLogin from '../Login/JellyfinLogin';
import axios from 'axios';
import { defineMessages, FormattedMessage } from 'react-intl';
import Accordion from '../Common/Accordion';
import { MediaServerType } from '../../../server/constants/server';
const messages = defineMessages({
welcome: 'Welcome to Jellyseerr',
welcome: 'Welcome to Overseerr',
signinMessage: 'Get started by signing in',
signinWithJellyfin: 'Use your Jellyfin account',
signinWithPlex: 'Use your Plex account',
});
interface LoginWithMediaServerProps {
onComplete: () => void;
onComplete: (onComplete: MediaServerType) => void;
}
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const [mediaServerType, setMediaServerType] = useState<number>(
const [mediaServerType, setMediaServerType] = useState<MediaServerType>(
MediaServerType.NOT_CONFIGURED
);
const { user, revalidate } = useUser();
@@ -46,23 +46,23 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
useEffect(() => {
if (user) {
onComplete();
onComplete(mediaServerType);
}
}, [user, onComplete]);
}, [user, mediaServerType, onComplete]);
return (
<div>
<div className="flex justify-center mb-2 text-xl font-bold">
<div className="mb-2 flex justify-center text-xl font-bold">
<FormattedMessage {...messages.welcome} />
</div>
<div className="flex justify-center pb-6 mb-2 text-sm">
<div className="mb-2 flex justify-center pb-6 text-sm">
<FormattedMessage {...messages.signinMessage} />
</div>
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full py-2 text-sm text-center hover:bg-gray-700 hover:cursor-pointer text-gray-400 transition-colors duration-200 bg-gray-900 cursor-default focus:outline-none sm:rounded-t-lg ${
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
onClick={() => handleClick(0)}
@@ -84,7 +84,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
</AccordionContent>
<div>
<button
className={`w-full py-2 text-sm text-center text-gray-400 transition-colors duration-200 bg-gray-900 cursor-default focus:outline-none hover:bg-gray-700 hover:cursor-pointer ${
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
@@ -95,7 +95,7 @@ const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div
className="px-10 py-8 rounded-b-lg"
className="rounded-b-lg px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<JellyfinLogin initial={true} revalidate={revalidate} />

View File

@@ -17,14 +17,14 @@ const SetupSteps: React.FC<CurrentStep> = ({
isLastStep = false,
}) => {
return (
<li className="relative md:flex-1 md:flex">
<div className="flex items-center px-6 py-4 space-x-4 text-sm font-medium leading-5">
<li className="relative md:flex md:flex-1">
<div className="flex items-center space-x-4 px-6 py-4 text-sm font-medium leading-5">
<div
className={`flex-shrink-0 w-10 h-10 flex items-center justify-center border-2
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center border-2
${active ? 'border-indigo-600 ' : 'border-white '}
${completed ? 'bg-indigo-600 border-indigo-600 ' : ''} rounded-full`}
${completed ? 'border-indigo-600 bg-indigo-600 ' : ''} rounded-full`}
>
{completed && <CheckIcon className="w-6 h-6 text-white" />}
{completed && <CheckIcon className="h-6 w-6 text-white" />}
{!completed && (
<p className={active ? 'text-white' : 'text-indigo-200'}>
{stepNumber}
@@ -32,7 +32,7 @@ const SetupSteps: React.FC<CurrentStep> = ({
)}
</div>
<p
className={`text-sm leading-5 font-medium ${
className={`text-sm font-medium leading-5 ${
active ? 'text-white' : 'text-indigo-200'
}`}
>
@@ -41,9 +41,9 @@ const SetupSteps: React.FC<CurrentStep> = ({
</div>
{!isLastStep && (
<div className="absolute top-0 right-0 hidden w-5 h-full md:block">
<div className="absolute top-0 right-0 hidden h-full w-5 md:block">
<svg
className="w-full h-full text-gray-600"
className="h-full w-full text-gray-600"
viewBox="0 0 22 80"
fill="none"
preserveAspectRatio="none"

View File

@@ -2,7 +2,8 @@ import axios from 'axios';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { mutate } from 'swr';
import useSWR, { mutate } from 'swr';
import { MediaServerType } from '../../../server/constants/server';
import useLocale from '../../hooks/useLocale';
import AppDataWarning from '../AppDataWarning';
import Badge from '../Common/Badge';
@@ -35,7 +36,9 @@ const Setup: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1);
const [mediaServerSettingsComplete, setMediaServerSettingsComplete] =
useState(false);
const [mediaServerType, setMediaServerType] = useState('');
const [mediaServerType, setMediaServerType] = useState(
MediaServerType.NOT_CONFIGURED
);
const router = useRouter();
const { locale } = useLocale();
@@ -54,38 +57,35 @@ const Setup: React.FC = () => {
}
};
const getMediaServerType = async () => {
const MainSettings = await axios.get('/api/v1/settings/main');
setMediaServerType(MainSettings.data.mediaServerType);
return;
};
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
refreshInterval: 0,
refreshWhenHidden: false,
revalidateOnFocus: false,
});
return (
<div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
<div className="relative flex min-h-screen flex-col justify-center bg-gray-900 py-12">
<PageTitle title={intl.formatMessage(messages.setup)} />
<ImageFader
backgroundImages={[
'/images/rotate1.jpg',
'/images/rotate2.jpg',
'/images/rotate3.jpg',
'/images/rotate4.jpg',
'/images/rotate5.jpg',
'/images/rotate6.jpg',
]}
backgroundImages={
backdrops?.map(
(backdrop) => `https://www.themoviedb.org/t/p/original${backdrop}`
) ?? []
}
/>
<div className="absolute z-50 top-4 right-4">
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-4xl">
<img
src="/logo_stacked.svg"
className="max-w-full mb-10 sm:max-w-md sm:mx-auto"
className="mb-10 max-w-full sm:mx-auto sm:max-w-md"
alt="Logo"
/>
<AppDataWarning />
<nav className="relative z-50">
<ul
className="bg-gray-800 bg-opacity-50 border border-gray-600 divide-y divide-gray-600 rounded-md md:flex md:divide-y-0"
className="divide-y divide-gray-600 rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 md:flex md:divide-y-0"
style={{ backdropFilter: 'blur(5px)' }}
>
<SetupSteps
@@ -108,27 +108,24 @@ const Setup: React.FC = () => {
/>
</ul>
</nav>
<div
style={{ backdropFilter: 'blur(5px)' }}
className="w-full p-4 mt-10 text-white bg-gray-800 border border-gray-600 rounded-md bg-opacity-40"
>
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
{currentStep === 1 && (
<SetupLogin
onComplete={() => {
getMediaServerType().then(() => {
setCurrentStep(2);
});
onComplete={(mServerType) => {
setMediaServerType(mServerType);
setCurrentStep(2);
}}
/>
)}
{currentStep === 2 && (
<div>
{mediaServerType == 'PLEX' ? (
{mediaServerType === MediaServerType.PLEX ? (
<SettingsPlex
onComplete={() => setMediaServerSettingsComplete(true)}
/>
) : (
<SettingsJellyfin
showAdvancedSettings={false}
onComplete={() => setMediaServerSettingsComplete(true)}
/>
)}
@@ -140,7 +137,7 @@ const Setup: React.FC = () => {
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
disabled={!mediaServerSettingsComplete}
@@ -158,7 +155,7 @@ const Setup: React.FC = () => {
<SettingsServices />
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
onClick={() => finishSetup()}

View File

@@ -155,7 +155,7 @@ const Slider: React.FC<SliderProps> = ({
return (
<div className="relative">
<div className="absolute right-0 flex -mt-10 text-gray-400">
<div className="absolute right-0 -mt-10 flex text-gray-400">
<button
className={`${
scrollPos.isStart ? 'text-gray-800' : 'hover:text-white'
@@ -163,7 +163,7 @@ const Slider: React.FC<SliderProps> = ({
onClick={() => slide(Direction.LEFT)}
disabled={scrollPos.isStart}
>
<ChevronLeftIcon className="w-6 h-6" />
<ChevronLeftIcon className="h-6 w-6" />
</button>
<button
className={`${
@@ -172,11 +172,11 @@ const Slider: React.FC<SliderProps> = ({
onClick={() => slide(Direction.RIGHT)}
disabled={scrollPos.isEnd}
>
<ChevronRightIcon className="w-6 h-6" />
<ChevronRightIcon className="h-6 w-6" />
</button>
</div>
<div
className="relative px-2 py-2 -my-2 -ml-4 -mr-4 overflow-x-scroll overflow-y-auto whitespace-nowrap hide-scrollbar overscroll-x-contain"
className="hide-scrollbar relative -my-2 -ml-4 -mr-4 overflow-y-auto overflow-x-scroll overscroll-x-contain whitespace-nowrap px-2 py-2"
ref={containerRef}
onScroll={onScroll}
>

View File

@@ -2,10 +2,13 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { MediaStatus } from '../../../server/constants/media';
import Spinner from '../../assets/spinner.svg';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Badge from '../Common/Badge';
const messages = defineMessages({
status: '{status}',
status4k: '4K {status}',
});
@@ -14,9 +17,8 @@ interface StatusBadgeProps {
is4k?: boolean;
inProgress?: boolean;
plexUrl?: string;
serviceUrl?: string;
tmdbId?: number;
mediaUrl?: string;
mediaUrl4k?: string;
mediaType?: 'movie' | 'tv';
}
@@ -24,159 +26,103 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
is4k = false,
inProgress = false,
mediaUrl,
mediaUrl4k,
plexUrl,
serviceUrl,
tmdbId,
mediaType,
}) => {
const intl = useIntl();
const { hasPermission } = useUser();
const settings = useSettings();
if (is4k) {
switch (status) {
case MediaStatus.AVAILABLE:
if (mediaUrl4k) {
return (
<a href={mediaUrl4k} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition !cursor-pointer hover:bg-green-400"
>
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.available),
})}
</Badge>
</a>
);
}
let mediaLink: string | undefined;
return (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.available),
})}
</Badge>
);
case MediaStatus.PARTIALLY_AVAILABLE:
if (mediaUrl4k) {
return (
<a href={mediaUrl4k} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition !cursor-pointer hover:bg-green-400"
>
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.partiallyavailable),
})}
</Badge>
</a>
);
}
return (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.partiallyavailable),
})}
</Badge>
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
<div className="flex items-center">
<span>
{intl.formatMessage(messages.status4k, {
status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.requested),
})}
</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PENDING:
return (
<Badge badgeType="warning">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.pending),
})}
</Badge>
);
default:
return null;
}
if (
mediaType &&
plexUrl &&
hasPermission(
is4k
? [
Permission.REQUEST_4K,
mediaType === 'movie'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
]
: [
Permission.REQUEST,
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{
type: 'or',
}
) &&
(!is4k ||
(mediaType === 'movie'
? settings.currentSettings.movie4kEnabled
: settings.currentSettings.series4kEnabled))
) {
mediaLink = plexUrl;
} else if (hasPermission(Permission.MANAGE_REQUESTS)) {
mediaLink =
mediaType && tmdbId ? `/${mediaType}/${tmdbId}?manage=1` : serviceUrl;
}
switch (status) {
case MediaStatus.AVAILABLE:
if (mediaUrl) {
return (
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition !cursor-pointer hover:bg-green-400"
>
<div className="flex items-center">
<span>{intl.formatMessage(globalMessages.available)}</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
</a>
);
}
return (
<Badge badgeType="success">
<div className="flex items-center">
<span>{intl.formatMessage(globalMessages.available)}</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PARTIALLY_AVAILABLE:
if (mediaUrl) {
return (
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition !cursor-pointer hover:bg-green-400"
>
<div className="flex items-center">
<span>
{intl.formatMessage(globalMessages.partiallyavailable)}
</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
</a>
);
}
return (
<Badge badgeType="success">
<div className="flex items-center">
<span>{intl.formatMessage(globalMessages.partiallyavailable)}</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
<Badge badgeType="success" href={mediaLink}>
<div className="flex items-center">
<span>
{inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.requested)}
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.available),
})}
</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
{inProgress && <Spinner className="ml-1 h-3 w-3" />}
</div>
</Badge>
);
case MediaStatus.PENDING:
case MediaStatus.PARTIALLY_AVAILABLE:
return (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
<Badge badgeType="success" href={mediaLink}>
<div className="flex items-center">
<span>
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.partiallyavailable),
})}
</span>
{inProgress && <Spinner className="ml-1 h-3 w-3" />}
</div>
</Badge>
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary" href={mediaLink}>
<div className="flex items-center">
<span>
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.requested),
})}
</span>
{inProgress && <Spinner className="ml-1 h-3 w-3" />}
</div>
</Badge>
);
case MediaStatus.PENDING:
return (
<Badge badgeType="warning" href={mediaLink}>
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.pending),
})}
</Badge>
);
default:
return null;
}

View File

@@ -1,8 +1,20 @@
import { SparklesIcon } from '@heroicons/react/outline';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { StatusResponse } from '../../../server/interfaces/api/settingsInterfaces';
import Modal from '../Common/Modal';
import Transition from '../Transition';
const messages = defineMessages({
newversionavailable: 'Application Update',
newversionDescription:
'Overseerr has been updated! Please click the button below to reload the page.',
reloadOverseerr: 'Reload',
});
const StatusChecker: React.FC = () => {
const intl = useIntl();
const { data, error } = useSWR<StatusResponse>('/api/v1/status', {
refreshInterval: 60 * 1000,
});
@@ -15,7 +27,28 @@ const StatusChecker: React.FC = () => {
return null;
}
return null;
return (
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
appear
show={data.commitTag !== process.env.commitTag}
>
<Modal
iconSvg={<SparklesIcon />}
title={intl.formatMessage(messages.newversionavailable)}
onOk={() => location.reload()}
okText={intl.formatMessage(messages.reloadOverseerr)}
backgroundClickable={false}
>
{intl.formatMessage(messages.newversionDescription)}
</Modal>
</Transition>
);
};
export default StatusChecker;

View File

@@ -88,7 +88,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
onCancel={closeModal}
/>
<div
className={`transition duration-300 transform-gpu outline-none cursor-default relative bg-gray-800 bg-cover rounded-xl ring-1 overflow-hidden ${
className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${
showDetail
? 'scale-105 shadow-lg ring-gray-500'
: 'scale-100 shadow ring-gray-700'
@@ -111,9 +111,9 @@ const TitleCard: React.FC<TitleCardProps> = ({
role="link"
tabIndex={0}
>
<div className="absolute inset-0 w-full h-full overflow-hidden">
<div className="absolute inset-0 h-full w-full overflow-hidden">
<CachedImage
className="absolute inset-0 w-full h-full"
className="absolute inset-0 h-full w-full"
alt=""
src={
image
@@ -125,34 +125,34 @@ const TitleCard: React.FC<TitleCardProps> = ({
/>
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`rounded-full z-40 pointer-events-none shadow ${
className={`pointer-events-none z-40 rounded-full shadow ${
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
}`}
>
<div className="flex items-center h-4 px-2 py-2 text-xs font-medium tracking-wider text-center text-white uppercase sm:h-5">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{mediaType === 'movie'
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
<div className="z-40 pointer-events-none">
<div className="pointer-events-none z-40">
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<div className="flex items-center justify-center w-4 h-4 text-white bg-green-400 rounded-full shadow sm:w-5 sm:h-5">
<CheckIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-green-400 text-white shadow sm:h-5 sm:w-5">
<CheckIcon className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
)}
{currentStatus === MediaStatus.PENDING && (
<div className="flex items-center justify-center w-4 h-4 text-white bg-yellow-500 rounded-full shadow sm:w-5 sm:h-5">
<BellIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-white shadow sm:h-5 sm:w-5">
<BellIcon className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
)}
{currentStatus === MediaStatus.PROCESSING && (
<div className="flex items-center justify-center w-4 h-4 text-white bg-indigo-500 rounded-full shadow sm:w-5 sm:h-5">
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-500 text-white shadow sm:h-5 sm:w-5">
{inProgress ? (
<Spinner className="w-3 h-3" />
<Spinner className="h-3 w-3" />
) : (
<ClockIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<ClockIcon className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</div>
)}
@@ -167,8 +167,8 @@ const TitleCard: React.FC<TitleCardProps> = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 z-40 flex items-center justify-center text-white bg-gray-800 bg-opacity-75 rounded-xl">
<Spinner className="w-10 h-10" />
<div className="absolute inset-0 z-40 flex items-center justify-center rounded-xl bg-gray-800 bg-opacity-75 text-white">
<Spinner className="h-10 w-10" />
</div>
</Transition>
@@ -184,13 +184,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
<div className="absolute inset-0 overflow-hidden rounded-xl">
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<a
className="absolute inset-0 w-full h-full overflow-hidden text-left cursor-pointer"
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="flex items-end w-full h-full">
<div className="flex h-full w-full items-end">
<div
className={`px-2 text-white ${
!showRequestButton ||
@@ -204,7 +204,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
)}
<h1
className="text-xl font-bold leading-tight whitespace-normal"
className="whitespace-normal text-xl font-bold leading-tight"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
@@ -216,7 +216,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
{title}
</h1>
<div
className="text-xs whitespace-normal"
className="whitespace-normal text-xs"
style={{
WebkitLineClamp:
!showRequestButton ||
@@ -247,7 +247,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
e.preventDefault();
setShowRequestModal(true);
}}
className="w-full h-7"
className="h-7 w-full"
>
<DownloadIcon />
<span>{intl.formatMessage(globalMessages.request)}</span>

Some files were not shown because too many files have changed in this diff Show More