Merge branch 'develop'
This commit is contained in:
1
src/assets/extlogos/gotify.svg
Normal file
1
src/assets/extlogos/gotify.svg
Normal 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 |
1
src/assets/services/trakt.svg
Normal file
1
src/assets/services/trakt.svg
Normal 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 |
@@ -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>
|
||||
|
||||
@@ -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 }> = ({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ const ConfirmButton: React.FC<ConfirmButtonProps> = ({
|
||||
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
79
src/components/IssueBlock/index.tsx
Normal file
79
src/components/IssueBlock/index.tsx
Normal 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;
|
||||
269
src/components/IssueDetails/IssueComment/index.tsx
Normal file
269
src/components/IssueDetails/IssueComment/index.tsx
Normal 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;
|
||||
157
src/components/IssueDetails/IssueDescription/index.tsx
Normal file
157
src/components/IssueDetails/IssueDescription/index.tsx
Normal 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;
|
||||
699
src/components/IssueDetails/index.tsx
Normal file
699
src/components/IssueDetails/index.tsx
Normal 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;
|
||||
275
src/components/IssueList/IssueItem/index.tsx
Normal file
275
src/components/IssueList/IssueItem/index.tsx
Normal 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;
|
||||
256
src/components/IssueList/index.tsx
Normal file
256
src/components/IssueList/index.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
SortDescendingIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces';
|
||||
import Button from '../../components/Common/Button';
|
||||
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Header from '../Common/Header';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import IssueItem from './IssueItem';
|
||||
|
||||
const messages = defineMessages({
|
||||
issues: 'Issues',
|
||||
sortAdded: '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;
|
||||
329
src/components/IssueModal/CreateIssueModal/index.tsx
Normal file
329
src/components/IssueModal/CreateIssueModal/index.tsx
Normal 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;
|
||||
34
src/components/IssueModal/constants.ts
Normal file
34
src/components/IssueModal/constants.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages, MessageDescriptor } from 'react-intl';
|
||||
import { IssueType } from '../../../server/constants/issue';
|
||||
|
||||
const messages = defineMessages({
|
||||
issueAudio: 'Audio',
|
||||
issueVideo: 'Video',
|
||||
issueSubtitles: '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,
|
||||
},
|
||||
];
|
||||
36
src/components/IssueModal/index.tsx
Normal file
36
src/components/IssueModal/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import Transition from '../Transition';
|
||||
import CreateIssueModal from './CreateIssueModal';
|
||||
|
||||
interface IssueModalProps {
|
||||
show?: boolean;
|
||||
onCancel: () => void;
|
||||
mediaType: 'movie' | 'tv';
|
||||
tmdbId: number;
|
||||
issueId?: never;
|
||||
}
|
||||
|
||||
const IssueModal: React.FC<IssueModalProps> = ({
|
||||
show,
|
||||
mediaType,
|
||||
onCancel,
|
||||
tmdbId,
|
||||
}) => (
|
||||
<Transition
|
||||
enter="transition opacity-0 duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition opacity-100 duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
show={show}
|
||||
>
|
||||
<CreateIssueModal
|
||||
mediaType={mediaType}
|
||||
onCancel={onCancel}
|
||||
tmdbId={tmdbId}
|
||||
/>
|
||||
</Transition>
|
||||
);
|
||||
|
||||
export default IssueModal;
|
||||
@@ -1,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('|')
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
506
src/components/ManageSlideOver/index.tsx
Normal file
506
src/components/ManageSlideOver/index.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
476
src/components/RequestModal/CollectionRequestModal.tsx
Normal file
476
src/components/RequestModal/CollectionRequestModal.tsx
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user