Merge branch 'develop' of https://github.com/sct/overseerr into jellyfin-support
This commit is contained in:
@@ -4,7 +4,6 @@ import useSWR from 'swr';
|
||||
import Alert from '../Common/Alert';
|
||||
|
||||
const messages = defineMessages({
|
||||
dockerVolumeMissing: 'Docker Volume Mount Missing',
|
||||
dockerVolumeMissingDescription:
|
||||
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
|
||||
});
|
||||
@@ -26,14 +25,14 @@ const AppDataWarning: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{!data.appData && (
|
||||
<Alert title={intl.formatMessage(messages.dockerVolumeMissing)}>
|
||||
{intl.formatMessage(messages.dockerVolumeMissingDescription, {
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.dockerVolumeMissingDescription, {
|
||||
code: function code(msg) {
|
||||
return <code className="bg-opacity-50">{msg}</code>;
|
||||
},
|
||||
appDataPath: data.appDataPath,
|
||||
})}
|
||||
</Alert>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
import { DownloadIcon, DuplicateIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import { uniq } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useState } from 'react';
|
||||
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 { LanguageContext } from '../../context/LanguageContext';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Error from '../../pages/_error';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
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 Slider from '../Slider';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import TitleCard from '../TitleCard';
|
||||
import Transition from '../Transition';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
|
||||
const messages = defineMessages({
|
||||
overviewunavailable: 'Overview unavailable.',
|
||||
overview: 'Overview',
|
||||
movies: 'Movies',
|
||||
numberofmovies: 'Number of Movies: {count}',
|
||||
requesting: 'Requesting…',
|
||||
request: 'Request',
|
||||
numberofmovies: '{count} Movies',
|
||||
requestcollection: 'Request Collection',
|
||||
requestswillbecreated:
|
||||
'The following titles will have requests created for them:',
|
||||
request4k: 'Request 4K',
|
||||
requestcollection4k: 'Request Collection in 4K',
|
||||
requestswillbecreated4k:
|
||||
'The following titles will have 4K requests created for them:',
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
});
|
||||
|
||||
interface CollectionDetailsProps {
|
||||
@@ -48,20 +47,23 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { addToast } = useToasts();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
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}?language=${locale}`,
|
||||
`/api/v1/collection/${router.query.collectionId}`,
|
||||
{
|
||||
initialData: collection,
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: genres } = useSWR<{ id: number; name: string }[]>(
|
||||
`/api/v1/genres/movie`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -105,6 +107,24 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE;
|
||||
}
|
||||
|
||||
const hasRequestable =
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
data.parts.filter(
|
||||
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
|
||||
).length > 0;
|
||||
|
||||
const hasRequestable4k =
|
||||
settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
data.parts.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||
).length > 0;
|
||||
|
||||
const requestableParts = data.parts.filter(
|
||||
(part) =>
|
||||
!part.mediaInfo ||
|
||||
@@ -147,14 +167,68 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const collectionAttributes: React.ReactNode[] = [];
|
||||
|
||||
collectionAttributes.push(
|
||||
intl.formatMessage(messages.numberofmovies, {
|
||||
count: data.parts.length,
|
||||
})
|
||||
);
|
||||
|
||||
if (genres && data.parts.some((part) => part.genreIds.length)) {
|
||||
collectionAttributes.push(
|
||||
uniq(
|
||||
data.parts.reduce(
|
||||
(genresList: number[], curr) => genresList.concat(curr.genreIds),
|
||||
[]
|
||||
)
|
||||
)
|
||||
.map((genreId) => (
|
||||
<Link
|
||||
href={`/discover/movies/genre/${genreId}`}
|
||||
key={`genre-${genreId}`}
|
||||
>
|
||||
<a className="hover:underline">
|
||||
{genres.find((g) => g.id === genreId)?.name}
|
||||
</a>
|
||||
</Link>
|
||||
))
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{intl.formatMessage(globalMessages.delimitedlist, {
|
||||
a: prev,
|
||||
b: curr,
|
||||
})}
|
||||
</>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
|
||||
className="media-page"
|
||||
style={{
|
||||
height: 493,
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<PageTitle title={data.name} />
|
||||
<Transition
|
||||
enter="opacity-0 transition duration-300"
|
||||
@@ -169,8 +243,10 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
onOk={() => requestBundle()}
|
||||
okText={
|
||||
isRequesting
|
||||
? intl.formatMessage(messages.requesting)
|
||||
: intl.formatMessage(is4k ? messages.request4k : messages.request)
|
||||
? intl.formatMessage(globalMessages.requesting)
|
||||
: intl.formatMessage(
|
||||
is4k ? globalMessages.request4k : globalMessages.request
|
||||
)
|
||||
}
|
||||
okDisabled={isRequesting}
|
||||
okButtonType="primary"
|
||||
@@ -178,22 +254,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.requestcollection4k : messages.requestcollection
|
||||
)}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconSvg={<DuplicateIcon />}
|
||||
>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
@@ -216,24 +277,29 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
</ul>
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
|
||||
<div className="lg:mr-4">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
|
||||
<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=""
|
||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2 space-x-2">
|
||||
<span className="ml-2 lg:ml-0">
|
||||
<StatusBadge
|
||||
status={collectionStatus}
|
||||
inProgress={data.parts.some(
|
||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<div className="media-title">
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={collectionStatus}
|
||||
inProgress={data.parts.some(
|
||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||
)}
|
||||
/>
|
||||
{settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
@@ -241,123 +307,79 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<span>
|
||||
<StatusBadge
|
||||
status={collectionStatus4k}
|
||||
is4k
|
||||
inProgress={data.parts.some(
|
||||
(part) =>
|
||||
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={collectionStatus4k}
|
||||
is4k
|
||||
inProgress={data.parts.some(
|
||||
(part) =>
|
||||
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
|
||||
<span className="mt-1 text-xs lg:text-base lg:mt-0">
|
||||
{intl.formatMessage(messages.numberofmovies, {
|
||||
count: data.parts.length,
|
||||
})}
|
||||
<h1>{data.name}</h1>
|
||||
<span className="media-attributes">
|
||||
{collectionAttributes.length > 0 &&
|
||||
collectionAttributes
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{prev} | {curr}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
|
||||
{hasPermission(Permission.REQUEST) &&
|
||||
(collectionStatus !== MediaStatus.AVAILABLE ||
|
||||
(settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
collectionStatus4k !== MediaStatus.AVAILABLE)) && (
|
||||
<div className="mb-3 sm:mb-0">
|
||||
<ButtonWithDropdown
|
||||
<div className="media-actions">
|
||||
{(hasRequestable || hasRequestable4k) && (
|
||||
<ButtonWithDropdown
|
||||
buttonType="primary"
|
||||
onClick={() => {
|
||||
setRequestModal(true);
|
||||
setIs4k(!hasRequestable);
|
||||
}}
|
||||
text={
|
||||
<>
|
||||
<DownloadIcon />
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
hasRequestable
|
||||
? messages.requestcollection
|
||||
: messages.requestcollection4k
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{hasRequestable && hasRequestable4k && (
|
||||
<ButtonWithDropdown.Item
|
||||
buttonType="primary"
|
||||
onClick={() => {
|
||||
setRequestModal(true);
|
||||
setIs4k(collectionStatus === MediaStatus.AVAILABLE);
|
||||
setIs4k(true);
|
||||
}}
|
||||
text={
|
||||
<>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
collectionStatus === MediaStatus.AVAILABLE
|
||||
? messages.requestcollection4k
|
||||
: messages.requestcollection
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
collectionStatus !== MediaStatus.AVAILABLE &&
|
||||
collectionStatus4k !== MediaStatus.AVAILABLE && (
|
||||
<ButtonWithDropdown.Item
|
||||
buttonType="primary"
|
||||
onClick={() => {
|
||||
setRequestModal(true);
|
||||
setIs4k(true);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestcollection4k)}
|
||||
</span>
|
||||
</ButtonWithDropdown.Item>
|
||||
)}
|
||||
</ButtonWithDropdown>
|
||||
</div>
|
||||
)}
|
||||
<DownloadIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestcollection4k)}
|
||||
</span>
|
||||
</ButtonWithDropdown.Item>
|
||||
)}
|
||||
</ButtonWithDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
||||
<div className="flex-1 md:mr-8">
|
||||
<h2 className="text-xl md:text-2xl">
|
||||
{intl.formatMessage(messages.overview)}
|
||||
</h2>
|
||||
<p className="pt-2 text-sm md:text-base">
|
||||
{data.overview
|
||||
? data.overview
|
||||
: intl.formatMessage(messages.overviewunavailable)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{intl.formatMessage(messages.movies)}</span>
|
||||
{data.overview && (
|
||||
<div className="media-overview">
|
||||
<div className="flex-1">
|
||||
<h2>{intl.formatMessage(messages.overview)}</h2>
|
||||
<p>{data.overview}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(globalMessages.movies)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="collection-movies"
|
||||
|
||||
@@ -1,90 +1,57 @@
|
||||
import {
|
||||
ExclamationIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import React from 'react';
|
||||
|
||||
interface AlertProps {
|
||||
title: string;
|
||||
title?: React.ReactNode;
|
||||
type?: 'warning' | 'info' | 'error';
|
||||
}
|
||||
|
||||
const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
|
||||
let design = {
|
||||
bgColor: 'bg-yellow-600',
|
||||
titleColor: 'text-yellow-200',
|
||||
titleColor: 'text-yellow-100',
|
||||
textColor: 'text-yellow-300',
|
||||
svg: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <ExclamationIcon className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'info':
|
||||
design = {
|
||||
bgColor: 'bg-indigo-600',
|
||||
titleColor: 'text-indigo-200',
|
||||
titleColor: 'text-indigo-100',
|
||||
textColor: 'text-indigo-300',
|
||||
svg: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <InformationCircleIcon className="w-5 h-5" />,
|
||||
};
|
||||
break;
|
||||
case 'error':
|
||||
design = {
|
||||
bgColor: 'bg-red-600',
|
||||
titleColor: 'text-red-200',
|
||||
titleColor: 'text-red-100',
|
||||
textColor: 'text-red-300',
|
||||
svg: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <XCircleIcon className="w-5 h-5" />,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md p-4 mb-5 ${design.bgColor}`}>
|
||||
<div className={`rounded-md p-4 mb-4 ${design.bgColor}`}>
|
||||
<div className="flex">
|
||||
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
|
||||
<div className="ml-3">
|
||||
<div className={`text-sm font-medium ${design.titleColor}`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
|
||||
{title && (
|
||||
<div className={`text-sm font-medium ${design.titleColor}`}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{children && (
|
||||
<div className={`mt-2 first:mt-0 text-sm ${design.textColor}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,52 +45,50 @@ function Button<P extends ElementTypes = 'button'>(
|
||||
ref?: React.Ref<Element<P>>
|
||||
): JSX.Element {
|
||||
const buttonStyle = [
|
||||
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer',
|
||||
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
|
||||
];
|
||||
switch (buttonType) {
|
||||
case 'primary':
|
||||
buttonStyle.push(
|
||||
'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50'
|
||||
'text-white bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 active:border-indigo-700'
|
||||
);
|
||||
break;
|
||||
case 'danger':
|
||||
buttonStyle.push(
|
||||
'text-white bg-red-600 hover:bg-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 disabled:opacity-50'
|
||||
'text-white bg-red-600 border-red-600 hover:bg-red-500 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700'
|
||||
);
|
||||
break;
|
||||
case 'warning':
|
||||
buttonStyle.push(
|
||||
'text-white bg-yellow-500 hover:bg-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 disabled:opacity-50'
|
||||
'text-white bg-yellow-500 border-yellow-500 hover:bg-yellow-400 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 active:border-yellow-700'
|
||||
);
|
||||
break;
|
||||
case 'success':
|
||||
buttonStyle.push(
|
||||
'text-white bg-green-400 hover:bg-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 disabled:opacity-50'
|
||||
'text-white bg-green-500 border-green-500 hover:bg-green-400 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700'
|
||||
);
|
||||
break;
|
||||
case 'ghost':
|
||||
buttonStyle.push(
|
||||
'text-white bg-transaprent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100 disabled:opacity-50'
|
||||
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||
);
|
||||
break;
|
||||
default:
|
||||
buttonStyle.push(
|
||||
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
|
||||
'text-gray-200 bg-gray-600 border-gray-600 hover:text-white hover:bg-gray-500 hover:border-gray-500 group-hover:text-white group-hover:bg-gray-500 group-hover:border-gray-500 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-500 active:border-gray-500'
|
||||
);
|
||||
}
|
||||
|
||||
switch (buttonSize) {
|
||||
case 'sm':
|
||||
buttonStyle.push('px-2.5 py-1.5 text-xs');
|
||||
break;
|
||||
case 'md':
|
||||
buttonStyle.push('px-4 py-2 text-sm');
|
||||
buttonStyle.push('px-2.5 py-1.5 text-xs button-sm');
|
||||
break;
|
||||
case 'lg':
|
||||
buttonStyle.push('px-6 py-3 text-base');
|
||||
buttonStyle.push('px-6 py-3 text-base button-lg');
|
||||
break;
|
||||
case 'md':
|
||||
default:
|
||||
buttonStyle.push('px-4 py-2 text-sm');
|
||||
buttonStyle.push('px-4 py-2 text-sm button-md');
|
||||
}
|
||||
|
||||
buttonStyle.push(className ?? '');
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ChevronDownIcon } from '@heroicons/react/solid';
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
AnchorHTMLAttributes,
|
||||
ReactNode,
|
||||
ButtonHTMLAttributes,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import Transition from '../../Transition';
|
||||
import { withProperties } from '../../../utils/typeHelpers';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
buttonType?: 'primary' | 'ghost';
|
||||
@@ -18,16 +19,16 @@ const DropdownItem: React.FC<DropdownItemProps> = ({
|
||||
buttonType = 'primary',
|
||||
...props
|
||||
}) => {
|
||||
let styleClass = '';
|
||||
let styleClass = 'button-md text-white';
|
||||
|
||||
switch (buttonType) {
|
||||
case 'ghost':
|
||||
styleClass =
|
||||
'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white';
|
||||
styleClass +=
|
||||
' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white';
|
||||
break;
|
||||
default:
|
||||
styleClass =
|
||||
'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white';
|
||||
styleClass +=
|
||||
' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
|
||||
}
|
||||
return (
|
||||
<a
|
||||
@@ -59,29 +60,28 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
||||
|
||||
const styleClasses = {
|
||||
mainButtonClasses: '',
|
||||
dropdownSideButtonClasses: '',
|
||||
dropdownClasses: '',
|
||||
mainButtonClasses: 'button-md text-white border',
|
||||
dropdownSideButtonClasses: 'button-md border',
|
||||
dropdownClasses: 'button-md',
|
||||
};
|
||||
|
||||
switch (buttonType) {
|
||||
case 'ghost':
|
||||
styleClasses.mainButtonClasses =
|
||||
'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||
styleClasses.dropdownSideButtonClasses =
|
||||
'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||
styleClasses.dropdownClasses = 'bg-gray-700';
|
||||
styleClasses.mainButtonClasses +=
|
||||
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
||||
styleClasses.dropdownClasses += ' bg-gray-700';
|
||||
break;
|
||||
default:
|
||||
styleClasses.mainButtonClasses =
|
||||
'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
|
||||
styleClasses.dropdownSideButtonClasses =
|
||||
'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
|
||||
styleClasses.dropdownClasses = 'bg-indigo-600';
|
||||
styleClasses.mainButtonClasses +=
|
||||
' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
||||
styleClasses.dropdownSideButtonClasses +=
|
||||
' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
|
||||
styleClasses.dropdownClasses += ' bg-indigo-600';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
|
||||
<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 ${
|
||||
@@ -93,29 +93,14 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
|
||||
{text}
|
||||
</button>
|
||||
{children && (
|
||||
<span className="relative z-10 block -ml-px">
|
||||
<span className="relative block -ml-px">
|
||||
<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 focus:z-10 ${styleClasses.dropdownSideButtonClasses}`}
|
||||
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}`}
|
||||
aria-label="Expand"
|
||||
onClick={() => setIsOpen((state) => !state)}
|
||||
>
|
||||
{dropdownIcon ? (
|
||||
dropdownIcon
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
||||
</button>
|
||||
<Transition
|
||||
show={isOpen}
|
||||
@@ -126,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 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
|
||||
<div className="absolute right-0 z-40 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
|
||||
<div
|
||||
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
|
||||
>
|
||||
|
||||
18
src/components/Common/CachedImage/index.tsx
Normal file
18
src/components/Common/CachedImage/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Image, { ImageProps } from 'next/image';
|
||||
import React from 'react';
|
||||
import useSettings from '../../../hooks/useSettings';
|
||||
|
||||
/**
|
||||
* The CachedImage component should be used wherever
|
||||
* we want to offer the option to locally cache images.
|
||||
*
|
||||
* It uses the `next/image` Image component but overrides
|
||||
* the `unoptimized` prop based on the application setting `cacheImages`.
|
||||
**/
|
||||
const CachedImage: React.FC<ImageProps> = (props) => {
|
||||
const { currentSettings } = useSettings();
|
||||
|
||||
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
|
||||
};
|
||||
|
||||
export default CachedImage;
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
HTMLAttributes,
|
||||
ForwardRefRenderFunction,
|
||||
HTMLAttributes,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import CachedImage from '../CachedImage';
|
||||
|
||||
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
backgroundImages: string[];
|
||||
rotationSpeed?: number;
|
||||
isDarker?: boolean;
|
||||
forceOptimize?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ROTATION_SPEED = 6000;
|
||||
@@ -18,6 +20,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
backgroundImages,
|
||||
rotationSpeed = DEFAULT_ROTATION_SPEED,
|
||||
isDarker,
|
||||
forceOptimize,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -43,19 +46,37 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
|
||||
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
|
||||
}
|
||||
|
||||
let overrides = {};
|
||||
|
||||
if (forceOptimize) {
|
||||
overrides = {
|
||||
unoptimized: false,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{backgroundImages.map((imageUrl, i) => (
|
||||
<div
|
||||
key={`banner-image-${i}`}
|
||||
className={`absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
|
||||
className={`absolute absolute-top-shift inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
|
||||
i === activeIndex ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: `${gradient}, url(${imageUrl})`,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<CachedImage
|
||||
className="absolute inset-0 w-full h-full"
|
||||
alt=""
|
||||
src={imageUrl}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
{...overrides}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundImage: gradient }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,15 +3,16 @@ import { withProperties } from '../../../utils/typeHelpers';
|
||||
|
||||
interface ListItemProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({ title, children }) => {
|
||||
const ListItem: React.FC<ListItemProps> = ({ title, className, children }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt className="block text-sm font-medium text-gray-400">{title}</dt>
|
||||
<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">
|
||||
<span className="flex-grow">{children}</span>
|
||||
<span className={`flex-grow ${className}`}>{children}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
TvResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from '../../../../server/models/Search';
|
||||
import TitleCard from '../../TitleCard';
|
||||
import useVerticalScroll from '../../../hooks/useVerticalScroll';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
noresults: 'No results.',
|
||||
});
|
||||
import TitleCard from '../../TitleCard';
|
||||
|
||||
interface ListViewProps {
|
||||
items?: (TvResult | MovieResult | PersonResult)[];
|
||||
@@ -34,11 +31,11 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
<>
|
||||
{isEmpty && (
|
||||
<div className="w-full mt-64 text-2xl text-center text-gray-400">
|
||||
{intl.formatMessage(messages.noresults)}
|
||||
{intl.formatMessage(globalMessages.noresults)}
|
||||
</div>
|
||||
)}
|
||||
<ul className="cardList">
|
||||
{items?.map((title) => {
|
||||
<ul className="cards-vertical">
|
||||
{items?.map((title, index) => {
|
||||
let titleCard: React.ReactNode;
|
||||
|
||||
switch (title.mediaType) {
|
||||
@@ -90,7 +87,7 @@ const ListView: React.FC<ListViewProps> = ({
|
||||
break;
|
||||
}
|
||||
|
||||
return <li key={title.id}>{titleCard}</li>;
|
||||
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
|
||||
})}
|
||||
{isLoading &&
|
||||
!isReachingEnd &&
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { MouseEvent, ReactNode, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Button, { ButtonType } from '../Button';
|
||||
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Transition from '../../Transition';
|
||||
import Button, { ButtonType } from '../Button';
|
||||
import CachedImage from '../CachedImage';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
|
||||
interface ModalProps {
|
||||
title?: string;
|
||||
@@ -29,6 +30,7 @@ interface ModalProps {
|
||||
backgroundClickable?: boolean;
|
||||
iconSvg?: ReactNode;
|
||||
loading?: boolean;
|
||||
backdrop?: string;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
@@ -53,6 +55,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
tertiaryDisabled = false,
|
||||
tertiaryText,
|
||||
onTertiary,
|
||||
backdrop,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -66,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-50"
|
||||
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"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
typeof onCancel === 'function' && backgroundClickable
|
||||
@@ -98,18 +101,35 @@ const Modal: React.FC<ModalProps> = ({
|
||||
show={!loading}
|
||||
>
|
||||
<div
|
||||
className="inline-block w-full max-h-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
|
||||
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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline"
|
||||
ref={modalRef}
|
||||
style={{
|
||||
maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)',
|
||||
}}
|
||||
>
|
||||
<div className="sm:flex sm:items-center">
|
||||
{iconSvg && (
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto text-white bg-gray-600 rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
{iconSvg}
|
||||
</div>
|
||||
)}
|
||||
{backdrop && (
|
||||
<div className="absolute top-0 left-0 right-0 z-0 w-full h-64">
|
||||
<CachedImage
|
||||
alt=""
|
||||
src={backdrop}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(180deg, rgba(55, 65, 81, 0.85) 0%, rgba(55, 65, 81, 1) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative sm:flex sm:items-center">
|
||||
{iconSvg && <div className="modal-icon">{iconSvg}</div>}
|
||||
<div
|
||||
className={`mt-3 text-center sm:mt-0 sm:text-left ${
|
||||
iconSvg ? 'sm:ml-4' : 'sm:mb-4'
|
||||
@@ -117,7 +137,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
>
|
||||
{title && (
|
||||
<span
|
||||
className="text-lg font-medium leading-6 text-white"
|
||||
className="text-lg font-bold leading-6 text-white"
|
||||
id="modal-headline"
|
||||
>
|
||||
{title}
|
||||
@@ -126,12 +146,12 @@ const Modal: React.FC<ModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="mt-4 text-sm leading-5 text-gray-300">
|
||||
<div className="relative mt-4 text-sm leading-5 text-gray-300">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{(onCancel || onOk || onSecondary || onTertiary) && (
|
||||
<div className="flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
|
||||
<div className="relative flex flex-row-reverse justify-center mt-5 sm:mt-4 sm:justify-start">
|
||||
{typeof onOk === 'function' && (
|
||||
<Button
|
||||
buttonType={okButtonType}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import ButtonWithDropdown from '../ButtonWithDropdown';
|
||||
|
||||
interface PlayButtonProps {
|
||||
@@ -8,6 +8,7 @@ interface PlayButtonProps {
|
||||
export interface PlayButtonLink {
|
||||
text: string;
|
||||
url: string;
|
||||
svg: ReactNode;
|
||||
}
|
||||
|
||||
const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
|
||||
@@ -20,26 +21,7 @@ const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
|
||||
buttonType="ghost"
|
||||
text={
|
||||
<>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{links[0].svg}
|
||||
<span>{links[0].text}</span>
|
||||
</>
|
||||
}
|
||||
@@ -57,7 +39,8 @@ const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
|
||||
}}
|
||||
buttonType="ghost"
|
||||
>
|
||||
{link.text}
|
||||
{link.svg}
|
||||
<span>{link.text}</span>
|
||||
</ButtonWithDropdown.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
74
src/components/Common/ProgressCircle/index.tsx
Normal file
74
src/components/Common/ProgressCircle/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface ProgressCircleProps {
|
||||
className?: string;
|
||||
progress?: number;
|
||||
useHeatLevel?: boolean;
|
||||
}
|
||||
|
||||
const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
||||
className,
|
||||
progress = 0,
|
||||
useHeatLevel,
|
||||
}) => {
|
||||
const ref = useRef<SVGCircleElement>(null);
|
||||
|
||||
let color = '';
|
||||
let emptyColor = 'text-gray-300';
|
||||
|
||||
if (useHeatLevel) {
|
||||
color = 'text-green-500';
|
||||
|
||||
if (progress <= 50) {
|
||||
color = 'text-yellow-500';
|
||||
}
|
||||
|
||||
if (progress <= 10) {
|
||||
color = 'text-red-500';
|
||||
}
|
||||
|
||||
if (progress === 0) {
|
||||
emptyColor = 'text-red-600';
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && ref.current) {
|
||||
const radius = ref.current?.r.baseVal.value;
|
||||
const circumference = (radius ?? 0) * 2 * Math.PI;
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
ref.current.style.strokeDashoffset = `${offset}`;
|
||||
ref.current.style.strokeDasharray = `${circumference} ${circumference}`;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<svg className={`${className} ${color}`} viewBox="0 0 24 24">
|
||||
<circle
|
||||
className={`${emptyColor} opacity-30`}
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
style={{
|
||||
transition: '0.35s stroke-dashoffset',
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: '50% 50%',
|
||||
}}
|
||||
ref={ref}
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="transparent"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressCircle;
|
||||
55
src/components/Common/SensitiveInput/index.tsx
Normal file
55
src/components/Common/SensitiveInput/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
|
||||
import { Field } from 'formik';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface CustomInputProps extends React.ComponentProps<'input'> {
|
||||
as?: 'input';
|
||||
}
|
||||
|
||||
interface CustomFieldProps extends React.ComponentProps<typeof Field> {
|
||||
as?: 'field';
|
||||
}
|
||||
|
||||
type SensitiveInputProps = CustomInputProps | CustomFieldProps;
|
||||
|
||||
const SensitiveInput: React.FC<SensitiveInputProps> = ({
|
||||
as = 'input',
|
||||
...props
|
||||
}) => {
|
||||
const [isHidden, setHidden] = useState(true);
|
||||
const Component = as === 'input' ? 'input' : Field;
|
||||
const componentProps =
|
||||
as === 'input'
|
||||
? props
|
||||
: {
|
||||
...props,
|
||||
as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Component
|
||||
{...componentProps}
|
||||
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
||||
type={
|
||||
isHidden
|
||||
? 'password'
|
||||
: props.type !== 'password'
|
||||
? props.type ?? 'text'
|
||||
: 'text'
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setHidden(!isHidden);
|
||||
}}
|
||||
type="button"
|
||||
className="input-action"
|
||||
>
|
||||
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SensitiveInput;
|
||||
171
src/components/Common/SettingsTabs/index.tsx
Normal file
171
src/components/Common/SettingsTabs/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { hasPermission, Permission } from '../../../../server/lib/permissions';
|
||||
import { useUser } from '../../../hooks/useUser';
|
||||
|
||||
export interface SettingsRoute {
|
||||
text: string;
|
||||
content?: React.ReactNode;
|
||||
route: string;
|
||||
regex: RegExp;
|
||||
requiredPermission?: Permission | Permission[];
|
||||
permissionType?: { type: 'and' | 'or' };
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const SettingsLink: React.FC<{
|
||||
tabType: 'default' | 'button';
|
||||
currentPath: string;
|
||||
route: string;
|
||||
regex: RegExp;
|
||||
hidden?: boolean;
|
||||
isMobile?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
tabType,
|
||||
currentPath,
|
||||
route,
|
||||
regex,
|
||||
hidden = false,
|
||||
isMobile = false,
|
||||
}) => {
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return <option value={route}>{children}</option>;
|
||||
}
|
||||
|
||||
let linkClasses =
|
||||
'px-1 py-4 ml-8 text-sm font-medium leading-5 transition duration-300 border-b-2 border-transparent whitespace-nowrap first:ml-0';
|
||||
let activeLinkColor = 'text-indigo-500 border-indigo-600';
|
||||
let inactiveLinkColor =
|
||||
'text-gray-500 border-transparent hover:text-gray-300 hover:border-gray-400 focus:text-gray-300 focus:border-gray-400';
|
||||
|
||||
if (tabType === 'button') {
|
||||
linkClasses =
|
||||
'px-3 py-2 text-sm font-medium transition duration-300 rounded-md whitespace-nowrap mx-2 my-1';
|
||||
activeLinkColor = 'bg-indigo-700';
|
||||
inactiveLinkColor = 'bg-gray-800 hover:bg-gray-700 focus:bg-gray-700';
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={route}>
|
||||
<a
|
||||
className={`${linkClasses} ${
|
||||
currentPath.match(regex) ? activeLinkColor : inactiveLinkColor
|
||||
}`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsTabs: React.FC<{
|
||||
tabType?: 'default' | 'button';
|
||||
settingsRoutes: SettingsRoute[];
|
||||
}> = ({ tabType = 'default', settingsRoutes }) => {
|
||||
const router = useRouter();
|
||||
const { user: currentUser } = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="tabs" className="sr-only">
|
||||
Select a Tab
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
defaultValue={
|
||||
settingsRoutes.find((route) => !!router.pathname.match(route.regex))
|
||||
?.route
|
||||
}
|
||||
aria-label="Selected Tab"
|
||||
>
|
||||
{settingsRoutes
|
||||
.filter(
|
||||
(route) =>
|
||||
!route.hidden &&
|
||||
(route.requiredPermission
|
||||
? hasPermission(
|
||||
route.requiredPermission,
|
||||
currentUser?.permissions ?? 0,
|
||||
route.permissionType
|
||||
)
|
||||
: true)
|
||||
)
|
||||
.map((route, index) => (
|
||||
<SettingsLink
|
||||
tabType={tabType}
|
||||
currentPath={router.pathname}
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
hidden={route.hidden ?? false}
|
||||
isMobile
|
||||
key={`mobile-settings-link-${index}`}
|
||||
>
|
||||
{route.text}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{tabType === 'button' ? (
|
||||
<div className="hidden sm:block">
|
||||
<nav className="flex flex-wrap -mx-2 -my-1" aria-label="Tabs">
|
||||
{settingsRoutes.map((route, index) => (
|
||||
<SettingsLink
|
||||
tabType={tabType}
|
||||
currentPath={router.pathname}
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
hidden={route.hidden ?? false}
|
||||
key={`button-settings-link-${index}`}
|
||||
>
|
||||
{route.content ?? route.text}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden overflow-x-scroll border-b border-gray-600 sm:block hide-scrollbar">
|
||||
<nav className="flex">
|
||||
{settingsRoutes
|
||||
.filter(
|
||||
(route) =>
|
||||
!route.hidden &&
|
||||
(route.requiredPermission
|
||||
? hasPermission(
|
||||
route.requiredPermission,
|
||||
currentUser?.permissions ?? 0,
|
||||
route.permissionType
|
||||
)
|
||||
: true)
|
||||
)
|
||||
.map((route, index) => (
|
||||
<SettingsLink
|
||||
tabType={tabType}
|
||||
currentPath={router.pathname}
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
key={`standard-settings-link-${index}`}
|
||||
>
|
||||
{route.text}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsTabs;
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { XIcon } from '@heroicons/react/outline';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Transition from '../../Transition';
|
||||
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
interface SlideOverProps {
|
||||
show?: boolean;
|
||||
@@ -43,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-50 bg-gray-800`}
|
||||
className={`z-50 fixed inset-0 overflow-hidden bg-opacity-70 bg-gray-800`}
|
||||
onClick={() => onClose()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -70,9 +71,9 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
|
||||
<header className="px-4 py-6 space-y-1 bg-indigo-600">
|
||||
<header className="px-4 space-y-1 bg-indigo-600 slideover">
|
||||
<div className="flex items-center justify-between space-x-3">
|
||||
<h2 className="text-lg font-medium leading-7 text-white">
|
||||
<h2 className="text-lg font-bold leading-7 text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center h-7">
|
||||
@@ -81,20 +82,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
|
||||
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<XIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const TH: React.FC<AllHTMLAttributes<HTMLTableHeaderCellElement>> = ({
|
||||
...props
|
||||
}) => {
|
||||
const style = [
|
||||
'px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider',
|
||||
'px-4 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider truncate',
|
||||
];
|
||||
|
||||
if (className) {
|
||||
@@ -39,7 +39,7 @@ const TD: React.FC<TDProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const style = ['whitespace-nowrap text-sm leading-5 text-white'];
|
||||
const style = ['text-sm leading-5 text-white'];
|
||||
|
||||
switch (alignText) {
|
||||
case 'left':
|
||||
@@ -54,7 +54,7 @@ const TD: React.FC<TDProps> = ({
|
||||
}
|
||||
|
||||
if (!noPadding) {
|
||||
style.push('px-6 py-4');
|
||||
style.push('px-4 py-4');
|
||||
}
|
||||
|
||||
if (className) {
|
||||
@@ -73,7 +73,7 @@ const Table: React.FC = ({ children }) => {
|
||||
<div className="flex flex-col">
|
||||
<div className="my-2 -mx-4 overflow-x-auto md:mx-0 lg:mx-0">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<div className="overflow-hidden shadow sm:rounded-lg">
|
||||
<div className="overflow-hidden rounded-lg shadow md:mx-0 lg:mx-0">
|
||||
<table className="min-w-full">{children}</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
48
src/components/CompanyCard/index.tsx
Normal file
48
src/components/CompanyCard/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface CompanyCardProps {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
|
||||
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 ${
|
||||
isHovered
|
||||
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||
: 'bg-gray-800 scale-100 ring-gray-700'
|
||||
} rounded-xl`}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
className="relative z-40 max-w-full max-h-full"
|
||||
/>
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t z-0 ${
|
||||
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyCard;
|
||||
62
src/components/Discover/DiscoverMovieGenre/index.tsx
Normal file
62
src/components/Discover/DiscoverMovieGenre/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
genreMovies: '{genre} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovieGenre: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<MovieResult, { genre: { id: number; name: string } }>(
|
||||
`/api/v1/discover/movies/genre/${router.query.genreId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.genreMovies, {
|
||||
genre: firstResultData?.genre.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovieGenre;
|
||||
71
src/components/Discover/DiscoverMovieLanguage/index.tsx
Normal file
71
src/components/Discover/DiscoverMovieLanguage/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
languageMovies: '{language} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovieLanguage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<
|
||||
MovieResult,
|
||||
{
|
||||
originalLanguage: {
|
||||
iso_639_1: string;
|
||||
english_name: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>(`/api/v1/discover/movies/language/${router.query.language}`);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.languageMovies, {
|
||||
language: intl.formatDisplayName(router.query.language as string, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovieLanguage;
|
||||
@@ -1,80 +1,40 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovermovies: 'Popular Movies',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const DiscoverMovies: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/movies?page=${pageIndex + 1}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>('/api/v1/discover/movies');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
const title = intl.formatMessage(messages.discovermovies);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discovermovies)} />
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.discovermovies} />
|
||||
</Header>
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
75
src/components/Discover/DiscoverNetwork/index.tsx
Normal file
75
src/components/Discover/DiscoverNetwork/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
import { TvNetwork } from '../../../../server/models/common';
|
||||
|
||||
const messages = defineMessages({
|
||||
networkSeries: '{network} Series',
|
||||
});
|
||||
|
||||
const DiscoverTvNetwork: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<TvResult, { network: TvNetwork }>(
|
||||
`/api/v1/discover/tv/network/${router.query.networkId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.networkSeries, {
|
||||
network: firstResultData?.network.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
{firstResultData?.network.logoPath ? (
|
||||
<div className="flex justify-center mb-6">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||
alt={firstResultData.network.name}
|
||||
className="max-h-24 sm:max-h-32"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvNetwork;
|
||||
75
src/components/Discover/DiscoverStudio/index.tsx
Normal file
75
src/components/Discover/DiscoverStudio/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
import { ProductionCompany } from '../../../../server/models/common';
|
||||
|
||||
const messages = defineMessages({
|
||||
studioMovies: '{studio} Movies',
|
||||
});
|
||||
|
||||
const DiscoverMovieStudio: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<MovieResult, { studio: ProductionCompany }>(
|
||||
`/api/v1/discover/movies/studio/${router.query.studioId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.studioMovies, {
|
||||
studio: firstResultData?.studio.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
{firstResultData?.studio.logoPath ? (
|
||||
<div className="flex justify-center mb-6">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||
alt={firstResultData.studio.name}
|
||||
className="max-h-24 sm:max-h-32"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverMovieStudio;
|
||||
@@ -1,79 +1,40 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
discovertv: 'Popular Series',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
const DiscoverTv: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/tv?page=${pageIndex + 1}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult>('/api/v1/discover/tv');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as TvResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
const title = intl.formatMessage(messages.discovertv);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discovertv)} />
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.discovertv} />
|
||||
</Header>
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
62
src/components/Discover/DiscoverTvGenre/index.tsx
Normal file
62
src/components/Discover/DiscoverTvGenre/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
genreSeries: '{genre} Series',
|
||||
});
|
||||
|
||||
const DiscoverTvGenre: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
firstResultData,
|
||||
} = useDiscover<TvResult, { genre: { id: number; name: string } }>(
|
||||
`/api/v1/discover/tv/genre/${router.query.genreId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.genreSeries, {
|
||||
genre: firstResultData?.genre.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvGenre;
|
||||
71
src/components/Discover/DiscoverTvLanguage/index.tsx
Normal file
71
src/components/Discover/DiscoverTvLanguage/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../../server/models/Search';
|
||||
import ListView from '../../Common/ListView';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../../Common/Header';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import { useRouter } from 'next/router';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import useDiscover from '../../../hooks/useDiscover';
|
||||
import Error from '../../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
languageSeries: '{language} Series',
|
||||
});
|
||||
|
||||
const DiscoverTvLanguage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<
|
||||
TvResult,
|
||||
{
|
||||
originalLanguage: {
|
||||
iso_639_1: string;
|
||||
english_name: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>(`/api/v1/discover/tv/language/${router.query.language}`);
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const title = isLoadingInitialData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.languageSeries, {
|
||||
language: intl.formatDisplayName(router.query.language as string, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={title} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{title}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvLanguage;
|
||||
@@ -1,81 +1,38 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
upcomingtv: 'Upcoming Series',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
const DiscoverTvUpcoming: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/tv/upcoming?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<TvResult>('/api/v1/discover/tv/upcoming');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as TvResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.upcomingtv} />
|
||||
</Header>
|
||||
<Header>{intl.formatMessage(messages.upcomingtv)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
54
src/components/Discover/MovieGenreList/index.tsx
Normal file
54
src/components/Discover/MovieGenreList/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { genreColorMap } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
moviegenres: 'Movie Genres',
|
||||
});
|
||||
|
||||
const MovieGenreList: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/movie`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.moviegenres)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{intl.formatMessage(messages.moviegenres)}</Header>
|
||||
</div>
|
||||
<ul className="cards-horizontal">
|
||||
{data.map((genre, index) => (
|
||||
<li key={`genre-${genre.id}-${index}`}>
|
||||
<GenreCard
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/movies/genre/${genre.id}`}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieGenreList;
|
||||
56
src/components/Discover/MovieGenreSlider/index.tsx
Normal file
56
src/components/Discover/MovieGenreSlider/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import Slider from '../../Slider';
|
||||
import { genreColorMap } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
moviegenres: 'Movie Genres',
|
||||
});
|
||||
|
||||
const MovieGenreSlider: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/movie`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/discover/movies/genres">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.moviegenres)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="movie-genres"
|
||||
isLoading={!data && !error}
|
||||
isEmpty={false}
|
||||
items={(data ?? []).map((genre, index) => (
|
||||
<GenreCard
|
||||
key={`genre-${genre.id}-${index}`}
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/movies/genre/${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(MovieGenreSlider);
|
||||
155
src/components/Discover/NetworkSlider/index.tsx
Normal file
155
src/components/Discover/NetworkSlider/index.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import CompanyCard from '../../CompanyCard';
|
||||
import Slider from '../../Slider';
|
||||
|
||||
const messages = defineMessages({
|
||||
networks: 'Networks',
|
||||
});
|
||||
|
||||
interface Network {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const networks: Network[] = [
|
||||
{
|
||||
name: 'Netflix',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
|
||||
url: '/discover/tv/network/213',
|
||||
},
|
||||
{
|
||||
name: 'Disney+',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
|
||||
url: '/discover/tv/network/2739',
|
||||
},
|
||||
{
|
||||
name: 'Prime Video',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
|
||||
url: '/discover/tv/network/1024',
|
||||
},
|
||||
{
|
||||
name: 'Apple TV+',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png',
|
||||
url: '/discover/tv/network/2552',
|
||||
},
|
||||
{
|
||||
name: 'HBO',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
|
||||
url: '/discover/tv/network/49',
|
||||
},
|
||||
{
|
||||
name: 'ABC',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
|
||||
url: '/discover/tv/network/2',
|
||||
},
|
||||
{
|
||||
name: 'FOX',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
|
||||
url: '/discover/tv/network/19',
|
||||
},
|
||||
{
|
||||
name: 'Cinemax',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
|
||||
url: '/discover/tv/network/359',
|
||||
},
|
||||
{
|
||||
name: 'AMC',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/pmvRmATOCaDykE6JrVoeYxlFHw3.png',
|
||||
url: '/discover/tv/network/174',
|
||||
},
|
||||
{
|
||||
name: 'Showtime',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
|
||||
url: '/discover/tv/network/67',
|
||||
},
|
||||
{
|
||||
name: 'Starz',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
|
||||
url: '/discover/tv/network/318',
|
||||
},
|
||||
{
|
||||
name: 'The CW',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
|
||||
url: '/discover/tv/network/71',
|
||||
},
|
||||
{
|
||||
name: 'NBC',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
|
||||
url: '/discover/tv/network/6',
|
||||
},
|
||||
{
|
||||
name: 'CBS',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
|
||||
url: '/discover/tv/network/16',
|
||||
},
|
||||
{
|
||||
name: 'BBC One',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
|
||||
url: '/discover/tv/network/4',
|
||||
},
|
||||
{
|
||||
name: 'Cartoon Network',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
|
||||
url: '/discover/tv/network/56',
|
||||
},
|
||||
{
|
||||
name: 'Adult Swim',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
|
||||
url: '/discover/tv/network/80',
|
||||
},
|
||||
{
|
||||
name: 'Nickelodeon',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
|
||||
url: '/discover/tv/network/13',
|
||||
},
|
||||
];
|
||||
|
||||
const NetworkSlider: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.networks)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="networks"
|
||||
isLoading={false}
|
||||
isEmpty={false}
|
||||
items={networks.map((network, index) => (
|
||||
<CompanyCard
|
||||
key={`network-${index}`}
|
||||
name={network.name}
|
||||
image={network.image}
|
||||
url={network.url}
|
||||
/>
|
||||
))}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkSlider;
|
||||
107
src/components/Discover/StudioSlider/index.tsx
Normal file
107
src/components/Discover/StudioSlider/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import CompanyCard from '../../CompanyCard';
|
||||
import Slider from '../../Slider';
|
||||
|
||||
const messages = defineMessages({
|
||||
studios: 'Studios',
|
||||
});
|
||||
|
||||
interface Studio {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const studios: Studio[] = [
|
||||
{
|
||||
name: 'Disney',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wdrCwmRnLFJhEoH8GSfymY85KHT.png',
|
||||
url: '/discover/movies/studio/2',
|
||||
},
|
||||
{
|
||||
name: '20th Century Fox',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
|
||||
url: '/discover/movies/studio/25',
|
||||
},
|
||||
{
|
||||
name: 'Sony Pictures',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/GagSvqWlyPdkFHMfQ3pNq6ix9P.png',
|
||||
url: '/discover/movies/studio/34',
|
||||
},
|
||||
{
|
||||
name: 'Warner Bros. Pictures',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ky0xOc5OrhzkZ1N6KyUxacfQsCk.png',
|
||||
url: '/discover/movies/studio/174',
|
||||
},
|
||||
{
|
||||
name: 'Universal',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png',
|
||||
url: '/discover/movies/studio/33',
|
||||
},
|
||||
{
|
||||
name: 'Paramount',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/fycMZt242LVjagMByZOLUGbCvv3.png',
|
||||
url: '/discover/movies/studio/4',
|
||||
},
|
||||
{
|
||||
name: 'Pixar',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png',
|
||||
url: '/discover/movies/studio/3',
|
||||
},
|
||||
{
|
||||
name: 'Dreamworks',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png',
|
||||
url: '/discover/movies/studio/521',
|
||||
},
|
||||
{
|
||||
name: 'Marvel Studios',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png',
|
||||
url: '/discover/movies/studio/420',
|
||||
},
|
||||
{
|
||||
name: 'DC',
|
||||
image:
|
||||
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png',
|
||||
url: '/discover/movies/studio/9993',
|
||||
},
|
||||
];
|
||||
|
||||
const StudioSlider: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.studios)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="studios"
|
||||
isLoading={false}
|
||||
isEmpty={false}
|
||||
items={studios.map((studio, index) => (
|
||||
<CompanyCard
|
||||
key={`studio-${index}`}
|
||||
name={studio.name}
|
||||
image={studio.image}
|
||||
url={studio.url}
|
||||
/>
|
||||
))}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioSlider;
|
||||
@@ -1,86 +1,43 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
PersonResult,
|
||||
} from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
trending: 'Trending',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (MovieResult | TvResult | PersonResult)[];
|
||||
}
|
||||
|
||||
const Trending: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/trending?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||
'/api/v1/discover/trending'
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.trending)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.trending} />
|
||||
</Header>
|
||||
<Header>{intl.formatMessage(messages.trending)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
54
src/components/Discover/TvGenreList/index.tsx
Normal file
54
src/components/Discover/TvGenreList/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { genreColorMap } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
seriesgenres: 'Series Genres',
|
||||
});
|
||||
|
||||
const TvGenreList: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/tv`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.seriesgenres)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>{intl.formatMessage(messages.seriesgenres)}</Header>
|
||||
</div>
|
||||
<ul className="cards-horizontal">
|
||||
{data.map((genre, index) => (
|
||||
<li key={`genre-${genre.id}-${index}`}>
|
||||
<GenreCard
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/tv/genre/${genre.id}`}
|
||||
canExpand
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TvGenreList;
|
||||
56
src/components/Discover/TvGenreSlider/index.tsx
Normal file
56
src/components/Discover/TvGenreSlider/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import Slider from '../../Slider';
|
||||
import { genreColorMap } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
tvgenres: 'Series Genres',
|
||||
});
|
||||
|
||||
const TvGenreSlider: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/tv`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="slider-header">
|
||||
<Link href="/discover/tv/genres">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.tvgenres)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="tv-genres"
|
||||
isLoading={!data && !error}
|
||||
isEmpty={false}
|
||||
items={(data ?? []).map((genre, index) => (
|
||||
<GenreCard
|
||||
key={`genre-tv-${genre.id}-${index}`}
|
||||
name={genre.name}
|
||||
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
|
||||
genreColorMap[genre.id] ?? genreColorMap[0]
|
||||
})${genre.backdrops[4]}`}
|
||||
url={`/discover/tv/genre/${genre.id}`}
|
||||
/>
|
||||
))}
|
||||
placeholder={<GenreCard.Placeholder />}
|
||||
emptyMessage=""
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TvGenreSlider);
|
||||
@@ -1,81 +1,38 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import React from 'react';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
upcomingmovies: 'Upcoming Movies',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const UpcomingMovies: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/movies/upcoming?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>('/api/v1/discover/movies/upcoming');
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.upcomingmovies)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.upcomingmovies} />
|
||||
</Header>
|
||||
<Header>{intl.formatMessage(messages.upcomingmovies)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
|
||||
63
src/components/Discover/constants.ts
Normal file
63
src/components/Discover/constants.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
type AvailableColors =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'darkred'
|
||||
| 'blue'
|
||||
| 'lightblue'
|
||||
| 'darkblue'
|
||||
| 'orange'
|
||||
| 'darkorange'
|
||||
| 'green'
|
||||
| 'lightgreen'
|
||||
| 'purple'
|
||||
| 'darkpurple'
|
||||
| 'yellow'
|
||||
| 'pink';
|
||||
|
||||
export const colorTones: Record<AvailableColors, [string, string]> = {
|
||||
red: ['991B1B', 'FCA5A5'],
|
||||
darkred: ['1F2937', 'F87171'],
|
||||
blue: ['032541', '01b4e4'],
|
||||
lightblue: ['1F2937', '60A5FA'],
|
||||
darkblue: ['1F2937', '2864d2'],
|
||||
orange: ['92400E', 'FCD34D'],
|
||||
lightgreen: ['065F46', '6EE7B7'],
|
||||
green: ['087d29', '21cb51'],
|
||||
purple: ['5B21B6', 'C4B5FD'],
|
||||
yellow: ['777e0d', 'e4ed55'],
|
||||
darkorange: ['552c01', 'd47c1d'],
|
||||
black: ['1F2937', 'D1D5DB'],
|
||||
pink: ['9D174D', 'F9A8D4'],
|
||||
darkpurple: ['480c8b', 'a96bef'],
|
||||
};
|
||||
|
||||
export const genreColorMap: Record<number, [string, string]> = {
|
||||
0: colorTones.black,
|
||||
28: colorTones.red, // Action
|
||||
12: colorTones.darkpurple, // Adventure
|
||||
16: colorTones.blue, // Animation
|
||||
35: colorTones.orange, // Comedy
|
||||
80: colorTones.darkblue, // Crime
|
||||
99: colorTones.lightgreen, // Documentary
|
||||
18: colorTones.pink, // Drama
|
||||
10751: colorTones.yellow, // Family
|
||||
14: colorTones.lightblue, // Fantasy
|
||||
36: colorTones.orange, // History
|
||||
27: colorTones.black, // Horror
|
||||
10402: colorTones.blue, // Music
|
||||
9648: colorTones.purple, // Mystery
|
||||
10749: colorTones.pink, // Romance
|
||||
878: colorTones.lightblue, // Science Fiction
|
||||
10770: colorTones.red, // TV Movie
|
||||
53: colorTones.black, // Thriller
|
||||
10752: colorTones.darkred, // War
|
||||
37: colorTones.orange, // Western
|
||||
10759: colorTones.darkpurple, // Action & Adventure
|
||||
10762: colorTones.blue, // Kids
|
||||
10763: colorTones.black, // News
|
||||
10764: colorTones.darkorange, // Reality
|
||||
10765: colorTones.lightblue, // Sci-Fi & Fantasy
|
||||
10766: colorTones.pink, // Soap
|
||||
10767: colorTones.lightgreen, // Talk
|
||||
10768: colorTones.darkred, // War & Politics
|
||||
};
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
||||
import Slider from '../Slider';
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||
import RequestCard from '../RequestCard';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import RequestCard from '../RequestCard';
|
||||
import Slider from '../Slider';
|
||||
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
|
||||
import MovieGenreSlider from './MovieGenreSlider';
|
||||
import NetworkSlider from './NetworkSlider';
|
||||
import StudioSlider from './StudioSlider';
|
||||
import TvGenreSlider from './TvGenreSlider';
|
||||
|
||||
const messages = defineMessages({
|
||||
discover: 'Discover',
|
||||
@@ -17,7 +22,7 @@ const messages = defineMessages({
|
||||
populartv: 'Popular Series',
|
||||
upcomingtv: 'Upcoming Series',
|
||||
recentlyAdded: 'Recently Added',
|
||||
nopending: 'No Pending Requests',
|
||||
noRequests: 'No requests.',
|
||||
upcoming: 'Upcoming Movies',
|
||||
trending: 'Trending',
|
||||
});
|
||||
@@ -26,26 +31,24 @@ const Discover: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
|
||||
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'
|
||||
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
|
||||
{ revalidateOnMount: true }
|
||||
);
|
||||
|
||||
const {
|
||||
data: requests,
|
||||
error: requestError,
|
||||
} = useSWR<RequestResultsResponse>(
|
||||
'/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0'
|
||||
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
|
||||
{ revalidateOnMount: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.discover)} />
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentlyAdded} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
@@ -60,30 +63,13 @@ const Discover: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/requests">
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>
|
||||
<FormattedMessage {...messages.recentrequests} />
|
||||
</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<Link href="/requests?filter=all">
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.recentrequests)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="requests"
|
||||
@@ -96,7 +82,7 @@ const Discover: React.FC = () => {
|
||||
/>
|
||||
))}
|
||||
placeholder={<RequestCard.Placeholder />}
|
||||
emptyMessage={intl.formatMessage(messages.nopending)}
|
||||
emptyMessage={intl.formatMessage(messages.noRequests)}
|
||||
/>
|
||||
<MediaSlider
|
||||
sliderKey="trending"
|
||||
@@ -110,24 +96,28 @@ const Discover: React.FC = () => {
|
||||
url="/api/v1/discover/movies"
|
||||
linkUrl="/discover/movies"
|
||||
/>
|
||||
<MovieGenreSlider />
|
||||
<MediaSlider
|
||||
sliderKey="upcoming"
|
||||
title={intl.formatMessage(messages.upcoming)}
|
||||
linkUrl="/discover/movies/upcoming"
|
||||
url="/api/v1/discover/movies/upcoming"
|
||||
/>
|
||||
<StudioSlider />
|
||||
<MediaSlider
|
||||
sliderKey="popular-tv"
|
||||
title={intl.formatMessage(messages.populartv)}
|
||||
url="/api/v1/discover/tv"
|
||||
linkUrl="/discover/tv"
|
||||
/>
|
||||
<TvGenreSlider />
|
||||
<MediaSlider
|
||||
sliderKey="upcoming-tv"
|
||||
title={intl.formatMessage(messages.upcomingtv)}
|
||||
url="/api/v1/discover/tv/upcoming"
|
||||
linkUrl="/discover/tv/upcoming"
|
||||
/>
|
||||
<NetworkSlider />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react';
|
||||
import { FormattedRelativeTime } from 'react-intl';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import { DownloadingItem } from '../../../server/lib/downloadtracker';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
estimatedtime: 'Estimated {time}',
|
||||
});
|
||||
|
||||
interface DownloadBlockProps {
|
||||
downloadItem: DownloadingItem;
|
||||
is4k?: boolean;
|
||||
@@ -12,6 +16,8 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
|
||||
downloadItem,
|
||||
is4k = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
|
||||
@@ -48,26 +54,30 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span>
|
||||
{is4k && (
|
||||
<Badge badgeType="warning" className="mr-1">
|
||||
<Badge badgeType="warning" className="mr-2">
|
||||
4K
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="capitalize">{downloadItem.status}</Badge>
|
||||
</span>
|
||||
<span>
|
||||
ETA{' '}
|
||||
{downloadItem.estimatedCompletionTime ? (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(downloadItem.estimatedCompletionTime).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
{downloadItem.estimatedCompletionTime
|
||||
? intl.formatMessage(messages.estimatedtime, {
|
||||
time: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(
|
||||
downloadItem.estimatedCompletionTime
|
||||
).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { MediaType } from '../../../server/constants/media';
|
||||
import { MediaServerType } from '../../../server/constants/server';
|
||||
import ImdbLogo from '../../assets/services/imdb.svg';
|
||||
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 TvdbLogo from '../../assets/services/tvdb.svg';
|
||||
import ImdbLogo from '../../assets/services/imdb.svg';
|
||||
import RTLogo from '../../assets/services/rt.svg';
|
||||
import PlexLogo from '../../assets/services/plex.svg';
|
||||
import JellyfinLogo from '../../assets/services/jellyfin.svg';
|
||||
import { MediaType } from '../../../server/constants/media';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaServerType } from '../../../server/constants/server';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
@@ -27,6 +28,8 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
mediaUrl,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const { locale } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
{mediaUrl && (
|
||||
@@ -49,8 +52,8 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
)}
|
||||
{tmdbId && (
|
||||
<a
|
||||
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
|
||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
||||
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}?language=${locale}`}
|
||||
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -60,7 +63,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
{tvdbId && mediaType === MediaType.TV && (
|
||||
<a
|
||||
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
||||
className="transition duration-300 opacity-50 w-9 hover:opacity-100"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -70,7 +73,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
{imdbId && (
|
||||
<a
|
||||
href={`https://www.imdb.com/title/${imdbId}`}
|
||||
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
|
||||
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -80,7 +83,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
{rtUrl && (
|
||||
<a
|
||||
href={`${rtUrl}`}
|
||||
className="mx-2 transition duration-300 opacity-50 w-14 hover:opacity-100"
|
||||
className="transition duration-300 opacity-50 w-14 hover:opacity-100"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
65
src/components/GenreCard/index.tsx
Normal file
65
src/components/GenreCard/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { withProperties } from '../../utils/typeHelpers';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
|
||||
interface GenreCardProps {
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
canExpand?: boolean;
|
||||
}
|
||||
|
||||
const GenreCard: React.FC<GenreCardProps> = ({
|
||||
image,
|
||||
url,
|
||||
name,
|
||||
canExpand = false,
|
||||
}) => {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Link href={url}>
|
||||
<a
|
||||
className={`relative flex items-center justify-center h-32 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 ${
|
||||
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`}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<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 ${
|
||||
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">
|
||||
{name}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
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`}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withProperties(GenreCard, { Placeholder: GenreCardPlaceholder });
|
||||
186
src/components/LanguageSelector/index.tsx
Normal file
186
src/components/LanguageSelector/index.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/* 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 useSWR from 'swr';
|
||||
import { Language } from '../../../server/lib/settings';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
originalLanguageDefault: 'All Languages',
|
||||
languageServerDefault: 'Default ({language})',
|
||||
});
|
||||
|
||||
const Select = dynamic(() => import('react-select'), { ssr: false });
|
||||
|
||||
type OptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
isFixed?: boolean;
|
||||
};
|
||||
|
||||
const selectStyles = {
|
||||
multiValueLabel: (base: any, state: { data: { isFixed?: boolean } }) => {
|
||||
return state.data.isFixed ? { ...base, paddingRight: 6 } : base;
|
||||
},
|
||||
multiValueRemove: (base: any, state: { data: { isFixed?: boolean } }) => {
|
||||
return state.data.isFixed ? { ...base, display: 'none' } : base;
|
||||
},
|
||||
};
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
value?: string;
|
||||
setFieldValue: (property: string, value: string) => void;
|
||||
serverValue?: string;
|
||||
isUserSettings?: boolean;
|
||||
}
|
||||
|
||||
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
value,
|
||||
setFieldValue,
|
||||
serverValue,
|
||||
isUserSettings = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||
|
||||
const sortedLanguages = useMemo(() => {
|
||||
languages?.forEach((language) => {
|
||||
language.name =
|
||||
intl.formatDisplayName(language.iso_639_1, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ?? language.english_name;
|
||||
});
|
||||
|
||||
return sortBy(languages, 'name');
|
||||
}, [intl, languages]);
|
||||
|
||||
const languageName = (languageCode: string) =>
|
||||
sortedLanguages?.find((language) => language.iso_639_1 === languageCode)
|
||||
?.name ?? languageCode;
|
||||
|
||||
const options: OptionType[] =
|
||||
sortedLanguages?.map((language) => ({
|
||||
label: language.name,
|
||||
value: language.iso_639_1,
|
||||
})) ?? [];
|
||||
|
||||
if (isUserSettings) {
|
||||
options.unshift({
|
||||
value: 'server',
|
||||
label: intl.formatMessage(messages.languageServerDefault, {
|
||||
language: serverValue
|
||||
? serverValue
|
||||
.split('|')
|
||||
.map((value) => languageName(value))
|
||||
.reduce((prev, curr) =>
|
||||
intl.formatMessage(globalMessages.delimitedlist, {
|
||||
a: prev,
|
||||
b: curr,
|
||||
})
|
||||
)
|
||||
: intl.formatMessage(messages.originalLanguageDefault),
|
||||
}),
|
||||
isFixed: true,
|
||||
});
|
||||
}
|
||||
|
||||
options.unshift({
|
||||
value: 'all',
|
||||
label: intl.formatMessage(messages.originalLanguageDefault),
|
||||
isFixed: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
isMulti
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
value={
|
||||
(isUserSettings && value === 'all') || (!isUserSettings && !value)
|
||||
? {
|
||||
value: 'all',
|
||||
label: intl.formatMessage(messages.originalLanguageDefault),
|
||||
isFixed: true,
|
||||
}
|
||||
: (value === '' || !value || value === 'server') && isUserSettings
|
||||
? {
|
||||
value: 'server',
|
||||
label: intl.formatMessage(messages.languageServerDefault, {
|
||||
language: serverValue
|
||||
? serverValue
|
||||
.split('|')
|
||||
.map((value) => languageName(value))
|
||||
.reduce((prev, curr) =>
|
||||
intl.formatMessage(globalMessages.delimitedlist, {
|
||||
a: prev,
|
||||
b: curr,
|
||||
})
|
||||
)
|
||||
: intl.formatMessage(messages.originalLanguageDefault),
|
||||
}),
|
||||
isFixed: true,
|
||||
}
|
||||
: value?.split('|').map((code) => {
|
||||
const matchedLanguage = sortedLanguages?.find(
|
||||
(lang) => lang.iso_639_1 === code
|
||||
);
|
||||
|
||||
if (!matchedLanguage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
label: matchedLanguage.name,
|
||||
value: matchedLanguage.iso_639_1,
|
||||
};
|
||||
}) ?? undefined
|
||||
}
|
||||
onChange={(
|
||||
value: OptionTypeBase | OptionsType<OptionType> | null,
|
||||
options
|
||||
) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(options &&
|
||||
options.action === 'select-option' &&
|
||||
options.option?.value === 'server') ||
|
||||
value?.every(
|
||||
(v: { value: string; label: string }) => v.value === 'server'
|
||||
)
|
||||
) {
|
||||
return setFieldValue('originalLanguage', '');
|
||||
}
|
||||
|
||||
if (
|
||||
(options &&
|
||||
options.action === 'select-option' &&
|
||||
options.option?.value === 'all') ||
|
||||
value?.every(
|
||||
(v: { value: string; label: string }) => v.value === 'all'
|
||||
)
|
||||
) {
|
||||
return setFieldValue('originalLanguage', isUserSettings ? 'all' : '');
|
||||
}
|
||||
|
||||
setFieldValue(
|
||||
'originalLanguage',
|
||||
value
|
||||
?.map((lang) => lang.value)
|
||||
.filter((v) => v !== 'all')
|
||||
.join('|')
|
||||
);
|
||||
}}
|
||||
styles={selectStyles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
@@ -1,87 +1,22 @@
|
||||
import React, { useState, useRef, useContext } from 'react';
|
||||
import Transition from '../../Transition';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { TranslateIcon } from '@heroicons/react/solid';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
LanguageContext,
|
||||
AvailableLocales,
|
||||
availableLanguages,
|
||||
AvailableLocale,
|
||||
} from '../../../context/LanguageContext';
|
||||
import { FormattedMessage, defineMessages } from 'react-intl';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import useLocale from '../../../hooks/useLocale';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
changelanguage: 'Change Language',
|
||||
displaylanguage: 'Display Language',
|
||||
});
|
||||
|
||||
type AvailableLanguageObject = Record<
|
||||
string,
|
||||
{ code: AvailableLocales; display: string }
|
||||
>;
|
||||
|
||||
const availableLanguages: AvailableLanguageObject = {
|
||||
de: {
|
||||
code: 'de',
|
||||
display: 'Deutsch',
|
||||
},
|
||||
en: {
|
||||
code: 'en',
|
||||
display: 'English',
|
||||
},
|
||||
es: {
|
||||
code: 'es',
|
||||
display: 'Español',
|
||||
},
|
||||
fr: {
|
||||
code: 'fr',
|
||||
display: 'Français',
|
||||
},
|
||||
it: {
|
||||
code: 'it',
|
||||
display: 'Italiano',
|
||||
},
|
||||
hu: {
|
||||
code: 'hu',
|
||||
display: 'Magyar',
|
||||
},
|
||||
nl: {
|
||||
code: 'nl',
|
||||
display: 'Nederlands',
|
||||
},
|
||||
'nb-NO': {
|
||||
code: 'nb-NO',
|
||||
display: 'Norsk Bokmål',
|
||||
},
|
||||
'pt-BR': {
|
||||
code: 'pt-BR',
|
||||
display: 'Português (Brasil)',
|
||||
},
|
||||
'pt-PT': {
|
||||
code: 'pt-PT',
|
||||
display: 'Português (Portugal)',
|
||||
},
|
||||
sv: {
|
||||
code: 'sv',
|
||||
display: 'Svenska',
|
||||
},
|
||||
ru: {
|
||||
code: 'ru',
|
||||
display: 'pусский',
|
||||
},
|
||||
sr: {
|
||||
code: 'sr',
|
||||
display: 'српски језик',
|
||||
},
|
||||
ja: {
|
||||
code: 'ja',
|
||||
display: '日本語',
|
||||
},
|
||||
'zh-TW': {
|
||||
code: 'zh-TW',
|
||||
display: '中文(臺灣)',
|
||||
},
|
||||
};
|
||||
|
||||
const LanguagePicker: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { locale, setLocale } = useContext(LanguageContext);
|
||||
const { locale, setLocale } = useLocale();
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
useClickOutside(dropdownRef, () => setDropdownOpen(false));
|
||||
|
||||
@@ -89,22 +24,13 @@ const LanguagePicker: React.FC = () => {
|
||||
<div className="relative">
|
||||
<div>
|
||||
<button
|
||||
className="p-1 text-gray-400 rounded-full hover:bg-gray-600 hover:text-white focus:outline-none focus:ring focus:text-white"
|
||||
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 ${
|
||||
isDropdownOpen ? 'bg-gray-600 text-white' : 'text-gray-400'
|
||||
}`}
|
||||
aria-label="Language Picker"
|
||||
onClick={() => setDropdownOpen(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7 2a1 1 0 011 1v1h3a1 1 0 110 2H9.578a18.87 18.87 0 01-1.724 4.78c.29.354.596.696.914 1.026a1 1 0 11-1.44 1.389c-.188-.196-.373-.396-.554-.6a19.098 19.098 0 01-3.107 3.567 1 1 0 01-1.334-1.49 17.087 17.087 0 003.13-3.733 18.992 18.992 0 01-1.487-2.494 1 1 0 111.79-.89c.234.47.489.928.764 1.372.417-.934.752-1.913.997-2.927H3a1 1 0 110-2h3V3a1 1 0 011-1zm6 6a1 1 0 01.894.553l2.991 5.982a.869.869 0 01.02.037l.99 1.98a1 1 0 11-1.79.895L15.383 16h-4.764l-.724 1.447a1 1 0 11-1.788-.894l.99-1.98.019-.038 2.99-5.982A1 1 0 0113 8zm-1.382 6h2.764L13 11.236 11.618 14z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<TranslateIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Transition
|
||||
@@ -124,18 +50,18 @@ const LanguagePicker: React.FC = () => {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="language"
|
||||
className="block pb-2 text-sm font-medium leading-5 text-gray-300"
|
||||
className="block pb-2 text-sm font-bold leading-5 text-gray-300"
|
||||
>
|
||||
<FormattedMessage {...messages.changelanguage} />
|
||||
{intl.formatMessage(messages.displaylanguage)}
|
||||
</label>
|
||||
<select
|
||||
id="language"
|
||||
className="rounded-md"
|
||||
onChange={(e) =>
|
||||
setLocale && setLocale(e.target.value as AvailableLocales)
|
||||
setLocale && setLocale(e.target.value as AvailableLocale)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
setLocale && setLocale(e.target.value as AvailableLocales)
|
||||
setLocale && setLocale(e.target.value as AvailableLocale)
|
||||
}
|
||||
defaultValue={locale}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BellIcon } from '@heroicons/react/outline';
|
||||
import React from 'react';
|
||||
|
||||
const Notifications: React.FC = () => {
|
||||
@@ -6,19 +7,7 @@ const Notifications: React.FC = () => {
|
||||
className="p-1 text-gray-400 rounded-full hover:bg-gray-500 hover:text-white focus:outline-none focus:ring focus:text-white"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<BellIcon className="w-6 h-6" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { XCircleIcon } from '@heroicons/react/outline';
|
||||
import { SearchIcon } from '@heroicons/react/solid';
|
||||
import React from 'react';
|
||||
import useSearchInput from '../../../hooks/useSearchInput';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import ClearButton from '../../../assets/xcircle.svg';
|
||||
import useSearchInput from '../../../hooks/useSearchInput';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: 'Search Movies & TV',
|
||||
@@ -12,26 +13,21 @@ const SearchInput: React.FC = () => {
|
||||
const { searchValue, setSearchValue, setIsOpen, clear } = useSearchInput();
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<div className="flex w-full md:ml-0">
|
||||
<div className="flex w-full">
|
||||
<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">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
/>
|
||||
</svg>
|
||||
<SearchIcon className="w-5 h-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 focus:border-gray-500 focus:outline-none focus:ring-0 focus:placeholder-gray-400 sm:text-base"
|
||||
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"
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
type="search"
|
||||
inputMode="search"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
@@ -46,7 +42,7 @@ const SearchInput: React.FC = () => {
|
||||
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"
|
||||
onClick={() => clear()}
|
||||
>
|
||||
<ClearButton />
|
||||
<XCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import Transition from '../../Transition';
|
||||
import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { useUser, Permission } from '../../../hooks/useUser';
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import Transition from '../../Transition';
|
||||
import VersionStatus from '../VersionStatus';
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: 'Discover',
|
||||
@@ -31,86 +39,26 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
{
|
||||
href: '/',
|
||||
messagesKey: 'dashboard',
|
||||
svgIcon: (
|
||||
<svg
|
||||
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svgIcon: <SparklesIcon className="w-6 h-6 mr-3" />,
|
||||
activeRegExp: /^\/(discover\/?(movies|tv)?)?$/,
|
||||
},
|
||||
{
|
||||
href: '/requests',
|
||||
messagesKey: 'requests',
|
||||
svgIcon: (
|
||||
<svg
|
||||
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svgIcon: <ClockIcon className="w-6 h-6 mr-3" />,
|
||||
activeRegExp: /^\/requests/,
|
||||
},
|
||||
{
|
||||
href: '/users',
|
||||
messagesKey: 'users',
|
||||
svgIcon: (
|
||||
<svg
|
||||
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||
</svg>
|
||||
),
|
||||
svgIcon: <UsersIcon className="w-6 h-6 mr-3" />,
|
||||
activeRegExp: /^\/users/,
|
||||
requiredPermission: Permission.MANAGE_USERS,
|
||||
},
|
||||
{
|
||||
href: '/settings',
|
||||
messagesKey: 'settings',
|
||||
svgIcon: (
|
||||
<svg
|
||||
className="w-6 h-6 mr-3 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svgIcon: <CogIcon className="w-6 h-6 mr-3" />,
|
||||
activeRegExp: /^\/settings/,
|
||||
requiredPermission: Permission.MANAGE_SETTINGS,
|
||||
},
|
||||
@@ -119,11 +67,13 @@ const SidebarLinks: SidebarLinkProps[] = [
|
||||
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
useClickOutside(navRef, () => setClosed());
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden">
|
||||
<div className="lg:hidden">
|
||||
<Transition show={open}>
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<Transition
|
||||
@@ -135,7 +85,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0">
|
||||
<div className="absolute inset-0 bg-gray-600 opacity-75"></div>
|
||||
<div className="absolute inset-0 bg-gray-900 opacity-90"></div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
@@ -147,40 +97,28 @@ 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">
|
||||
<div className="absolute top-0 right-0 p-1 -mr-14">
|
||||
<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">
|
||||
<button
|
||||
className="flex items-center justify-center w-12 h-12 rounded-full focus:outline-none focus:bg-gray-600"
|
||||
aria-label="Close sidebar"
|
||||
onClick={() => setClosed()}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<XIcon className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={navRef}
|
||||
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
|
||||
className="flex flex-col flex-1 h-0 pt-8 pb-8 overflow-y-auto sm:pb-4"
|
||||
>
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<span className="text-xl text-gray-50">
|
||||
<div className="flex items-center flex-shrink-0 px-2">
|
||||
<span className="px-4 text-xl text-gray-50">
|
||||
<a href="/">
|
||||
<img src="/logo.png" alt="Logo" />
|
||||
<img src="/logo_full.svg" alt="Logo" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="px-2 mt-5 space-y-1">
|
||||
<nav className="flex-1 px-4 mt-16 space-y-4">
|
||||
{SidebarLinks.filter((link) =>
|
||||
link.requiredPermission
|
||||
? hasPermission(link.requiredPermission)
|
||||
@@ -201,25 +139,30 @@ 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 focus:bg-gray-700 transition ease-in-out duration-150
|
||||
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
|
||||
${
|
||||
router.pathname.match(
|
||||
sidebarLink.activeRegExp
|
||||
)
|
||||
? 'bg-gray-900'
|
||||
: ''
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
|
||||
: 'hover:bg-gray-700 focus:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{sidebarLink.svgIcon}
|
||||
<FormattedMessage
|
||||
{...messages[sidebarLink.messagesKey]}
|
||||
/>
|
||||
{intl.formatMessage(
|
||||
messages[sidebarLink.messagesKey]
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{hasPermission(Permission.ADMIN) && (
|
||||
<div className="px-2">
|
||||
<VersionStatus onClick={() => setClosed()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-14">
|
||||
@@ -231,18 +174,18 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div className="fixed top-0 bottom-0 left-0 hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col flex-1 h-0 bg-gray-800">
|
||||
<div className="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<span className="text-2xl text-gray-50">
|
||||
<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">
|
||||
<span className="px-4 text-2xl text-gray-50">
|
||||
<a href="/">
|
||||
<img src="/logo.png" alt="Logo" />
|
||||
<img src="/logo_full.svg" alt="Logo" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-2 mt-5 space-y-1 bg-gray-800">
|
||||
<nav className="flex-1 px-4 mt-16 space-y-4">
|
||||
{SidebarLinks.filter((link) =>
|
||||
link.requiredPermission
|
||||
? hasPermission(link.requiredPermission)
|
||||
@@ -255,25 +198,28 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
||||
as={sidebarLink.as}
|
||||
>
|
||||
<a
|
||||
className={`flex group items-center px-2 py-2 text-base leading-6 font-medium rounded-md text-white hover:text-gray-100 hover:bg-gray-700 focus:outline-none focus:bg-gray-700 transition ease-in-out duration-150
|
||||
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
|
||||
${
|
||||
router.pathname.match(
|
||||
sidebarLink.activeRegExp
|
||||
)
|
||||
? 'bg-gray-900'
|
||||
: ''
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500'
|
||||
: 'hover:bg-gray-700 focus:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{sidebarLink.svgIcon}
|
||||
<FormattedMessage
|
||||
{...messages[sidebarLink.messagesKey]}
|
||||
/>
|
||||
{intl.formatMessage(messages[sidebarLink.messagesKey])}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{hasPermission(Permission.ADMIN) && (
|
||||
<div className="px-2">
|
||||
<VersionStatus />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import Transition from '../../Transition';
|
||||
import { useUser } from '../../../hooks/useUser';
|
||||
import { LogoutIcon } from '@heroicons/react/outline';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Link from 'next/link';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import { useUser } from '../../../hooks/useUser';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
myprofile: 'Profile',
|
||||
@@ -31,13 +33,17 @@ const UserDropdown: React.FC = () => {
|
||||
<div className="relative ml-3">
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center max-w-xs text-sm rounded-full focus:outline-none focus:ring"
|
||||
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"
|
||||
id="user-menu"
|
||||
aria-label="User menu"
|
||||
aria-haspopup="true"
|
||||
onClick={() => setDropdownOpen(true)}
|
||||
>
|
||||
<img className="w-8 h-8 rounded-full" src={user?.avatar} alt="" />
|
||||
<img
|
||||
className="w-8 h-8 rounded-full sm:w-10 sm:h-10"
|
||||
src={user?.avatar}
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Transition
|
||||
@@ -61,7 +67,7 @@ const UserDropdown: React.FC = () => {
|
||||
>
|
||||
<Link href={`/profile`}>
|
||||
<a
|
||||
className="block px-4 py-2 text-sm text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
|
||||
className="flex items-center px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -71,12 +77,13 @@ const UserDropdown: React.FC = () => {
|
||||
}}
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
>
|
||||
{intl.formatMessage(messages.myprofile)}
|
||||
<UserIcon className="inline w-5 h-5 mr-2" />
|
||||
<span>{intl.formatMessage(messages.myprofile)}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/profile/settings`}>
|
||||
<a
|
||||
className="block px-4 py-2 text-sm text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
|
||||
className="flex items-center px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
@@ -86,16 +93,18 @@ const UserDropdown: React.FC = () => {
|
||||
}}
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
>
|
||||
{intl.formatMessage(messages.settings)}
|
||||
<CogIcon className="inline w-5 h-5 mr-2" />
|
||||
<span>{intl.formatMessage(messages.settings)}</span>
|
||||
</a>
|
||||
</Link>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-4 py-2 text-sm text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
|
||||
className="flex items-center px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out hover:bg-gray-600"
|
||||
role="menuitem"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
{intl.formatMessage(messages.signout)}
|
||||
<LogoutIcon className="inline w-5 h-5 mr-2" />
|
||||
<span>{intl.formatMessage(messages.signout)}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
90
src/components/Layout/VersionStatus/index.tsx
Normal file
90
src/components/Layout/VersionStatus/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ArrowCircleUpIcon,
|
||||
BeakerIcon,
|
||||
CodeIcon,
|
||||
ServerIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
|
||||
const messages = defineMessages({
|
||||
streamdevelop: 'Overseerr Develop',
|
||||
streamstable: 'Overseerr Stable',
|
||||
outofdate: 'Out of Date',
|
||||
commitsbehind:
|
||||
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
|
||||
});
|
||||
|
||||
interface VersionStatusProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const VersionStatus: React.FC<VersionStatusProps> = ({ onClick }) => {
|
||||
const intl = useIntl();
|
||||
const { data } = useSWR<StatusResponse>('/api/v1/status', {
|
||||
refreshInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const versionStream =
|
||||
data.commitTag === 'local'
|
||||
? 'Keep it up! 👍'
|
||||
: data.version.startsWith('develop-')
|
||||
? intl.formatMessage(messages.streamdevelop)
|
||||
: intl.formatMessage(messages.streamstable);
|
||||
|
||||
return (
|
||||
<Link href="/settings/about">
|
||||
<a
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && onClick) {
|
||||
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 ${
|
||||
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" />
|
||||
) : data.version.startsWith('develop-') ? (
|
||||
<BeakerIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<ServerIcon className="w-6 h-6" />
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0 px-2 truncate last:pr-0">
|
||||
<span className="font-bold">{versionStream}</span>
|
||||
<span className="truncate">
|
||||
{data.commitTag === 'local' ? (
|
||||
'(⌐■_■)'
|
||||
) : data.commitsBehind > 0 ? (
|
||||
intl.formatMessage(messages.commitsbehind, {
|
||||
commitsBehind: data.commitsBehind,
|
||||
})
|
||||
) : data.commitsBehind === -1 ? (
|
||||
intl.formatMessage(messages.outofdate)
|
||||
) : (
|
||||
<code className="p-0 bg-transparent">
|
||||
{data.version.replace('develop-', '')}
|
||||
</code>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{data.updateAvailable && <ArrowCircleUpIcon className="w-6 h-6" />}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default VersionStatus;
|
||||
@@ -1,26 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import SearchInput from './SearchInput';
|
||||
import UserDropdown from './UserDropdown';
|
||||
import Sidebar from './Sidebar';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import { MenuAlt2Icon } from '@heroicons/react/outline';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
|
||||
const messages = defineMessages({
|
||||
alphawarning:
|
||||
'This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!',
|
||||
});
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AvailableLocale } from '../../context/LanguageContext';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import SearchInput from './SearchInput';
|
||||
import Sidebar from './Sidebar';
|
||||
import UserDropdown from './UserDropdown';
|
||||
|
||||
const Layout: React.FC = ({ children }) => {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const { hasPermission } = useUser();
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const { currentSettings } = useSettings();
|
||||
const { setLocale } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
if (setLocale && user) {
|
||||
setLocale(
|
||||
(user?.settings?.locale
|
||||
? user.settings.locale
|
||||
: currentSettings.locale) as AvailableLocale
|
||||
);
|
||||
}
|
||||
}, [setLocale, currentSettings.locale, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScrolled = () => {
|
||||
if (window.pageYOffset > 60) {
|
||||
if (window.pageYOffset > 20) {
|
||||
setIsScrolled(true);
|
||||
} else {
|
||||
setIsScrolled(false);
|
||||
@@ -36,44 +46,42 @@ const Layout: React.FC = ({ children }) => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 min-h-full bg-gray-900">
|
||||
<div className="absolute w-full h-64 from-gray-800 to-gray-900 bg-gradient-to-bl">
|
||||
<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>
|
||||
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 md:ml-64">
|
||||
<div className="relative flex flex-col flex-1 w-0 min-w-0 mb-16 lg:ml-64">
|
||||
<div
|
||||
className={`fixed left-0 right-0 z-10 flex flex-shrink-0 h-16 bg-opacity-80 transition duration-300 ${
|
||||
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'
|
||||
} md:left-64`}
|
||||
} lg:left-64`}
|
||||
style={{
|
||||
backdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
||||
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="px-4 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
|
||||
className={`px-4 text-white ${
|
||||
isScrolled ? 'opacity-90' : 'opacity-70'
|
||||
} focus:outline-none lg:hidden transition duration-300`}
|
||||
aria-label="Open sidebar"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
</svg>
|
||||
<MenuAlt2Icon className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4">
|
||||
<div className="flex items-center justify-between flex-1 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`}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="w-7" />
|
||||
</button>
|
||||
<SearchInput />
|
||||
<div className="flex items-center ml-2 md:ml-4">
|
||||
<LanguagePicker />
|
||||
<div className="flex items-center">
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,44 +89,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
|
||||
<main className="relative z-0 top-16 focus:outline-none" tabIndex={0}>
|
||||
<div className="mb-6">
|
||||
<div className="px-4 mx-auto max-w-8xl">
|
||||
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
|
||||
<div className="p-4 mt-6 bg-indigo-700 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 ml-3 md:flex md:justify-between">
|
||||
<p className="text-sm leading-5 text-white">
|
||||
<FormattedMessage {...messages.alphawarning} />
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
|
||||
<a
|
||||
href="http://github.com/sct/overseerr"
|
||||
className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<div className="px-4 mx-auto max-w-8xl">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
73
src/components/LoadingBar/index.tsx
Normal file
73
src/components/LoadingBar/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NProgress } from '@tanem/react-nprogress';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface BarProps {
|
||||
progress: number;
|
||||
isFinished: boolean;
|
||||
}
|
||||
|
||||
const Bar = ({ progress, isFinished }: BarProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 z-50 w-full transition-opacity ease-out duration-400 ${
|
||||
isFinished ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="duration-300 bg-indigo-400 transition-width"
|
||||
style={{
|
||||
height: '3px',
|
||||
width: `${progress * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NProgressBar = ({ loading }: { loading: boolean }) => (
|
||||
<NProgress isAnimating={loading}>
|
||||
{({ isFinished, progress }) => (
|
||||
<Bar progress={progress} isFinished={isFinished} />
|
||||
)}
|
||||
</NProgress>
|
||||
);
|
||||
|
||||
const MemoizedNProgress = React.memo(NProgressBar);
|
||||
|
||||
const LoadingBar = (): React.ReactPortal | null => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLoading = () => {
|
||||
setLoading(true);
|
||||
};
|
||||
const handleFinishedLoading = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
router.events.on('routeChangeStart', handleLoading);
|
||||
router.events.on('routeChangeComplete', handleFinishedLoading);
|
||||
router.events.on('routeChangeError', handleFinishedLoading);
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeStart', handleLoading);
|
||||
router.events.off('routeChangeComplete', handleFinishedLoading);
|
||||
router.events.off('routeChangeError', handleFinishedLoading);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return mounted
|
||||
? ReactDOM.createPortal(
|
||||
<MemoizedNProgress loading={loading} />,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
export default LoadingBar;
|
||||
@@ -1,18 +1,21 @@
|
||||
import { LoginIcon, SupportIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Button from '../Common/Button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import Button from '../Common/Button';
|
||||
import SensitiveInput from '../Common/SensitiveInput';
|
||||
|
||||
const messages = defineMessages({
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
validationemailrequired: 'Not a valid email address',
|
||||
validationpasswordrequired: 'Password required',
|
||||
validationemailrequired: 'You must provide a valid email address',
|
||||
validationpasswordrequired: 'You must provide a password',
|
||||
loginerror: 'Something went wrong while trying to sign in.',
|
||||
signingin: 'Signing in…',
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
});
|
||||
@@ -23,6 +26,7 @@ interface LocalLoginProps {
|
||||
|
||||
const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
@@ -34,6 +38,10 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
),
|
||||
});
|
||||
|
||||
const passwordResetEnabled =
|
||||
settings.currentSettings.applicationUrl &&
|
||||
settings.currentSettings.emailEnabled;
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@@ -58,17 +66,17 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<div>
|
||||
<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="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="name@example.com"
|
||||
inputMode="email"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
@@ -79,12 +87,13 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
@@ -98,25 +107,33 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Link href="/resetpassword" passHref>
|
||||
<Button as="a" buttonType="ghost">
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
<LoginIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
{passwordResetEnabled && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Link href="/resetpassword" passHref>
|
||||
<Button as="a" buttonType="ghost">
|
||||
<SupportIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PlexLoginButton from '../PlexLoginButton';
|
||||
import JellyfinLogin from './JellyfinLogin';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import { XCircleIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Transition from '../Transition';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
import LocalLogin from './LocalLogin';
|
||||
import Accordion from '../Common/Accordion';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { MediaServerType } from '../../../server/constants/server';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import Accordion from '../Common/Accordion';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
import PlexLoginButton from '../PlexLoginButton';
|
||||
import Transition from '../Transition';
|
||||
import JellyfinLogin from './JellyfinLogin';
|
||||
import LocalLogin from './LocalLogin';
|
||||
|
||||
const messages = defineMessages({
|
||||
signin: 'Sign In',
|
||||
@@ -66,6 +67,7 @@ const Login: React.FC = () => {
|
||||
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
|
||||
<PageTitle title={intl.formatMessage(messages.signin)} />
|
||||
<ImageFader
|
||||
forceOptimize
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
@@ -78,10 +80,10 @@ const Login: React.FC = () => {
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img src="/logo.png" className="w-auto mx-auto max-h-32" alt="Logo" />
|
||||
<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">
|
||||
<FormattedMessage {...messages.signinheader} />
|
||||
{intl.formatMessage(messages.signinheader)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
@@ -102,19 +104,7 @@ const Login: React.FC = () => {
|
||||
<div className="p-4 mb-4 bg-red-600 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<XCircleIcon className="w-5 h-5 text-red-300" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-300">
|
||||
@@ -128,7 +118,7 @@ const Login: React.FC = () => {
|
||||
{({ openIndexes, handleClick, AccordionContent }) => (
|
||||
<>
|
||||
<button
|
||||
className={`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={`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 ${
|
||||
openIndexes.includes(0) && 'text-indigo-500'
|
||||
} ${
|
||||
settings.currentSettings.localLogin &&
|
||||
@@ -158,7 +148,7 @@ const Login: React.FC = () => {
|
||||
{settings.currentSettings.localLogin && (
|
||||
<div>
|
||||
<button
|
||||
className={`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={`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 ${
|
||||
openIndexes.includes(1)
|
||||
? 'text-indigo-500'
|
||||
: 'sm:rounded-b-lg'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
@@ -32,8 +33,10 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
|
||||
>
|
||||
<div
|
||||
className={`relative w-36 sm:w-36 md:w-44
|
||||
rounded-lg text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
||||
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
|
||||
rounded-xl text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||
isHovered
|
||||
? 'bg-gray-600 ring-gray-500 scale-105'
|
||||
: 'bg-gray-800 ring-gray-700 scale-100'
|
||||
}`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
@@ -77,18 +80,7 @@ const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
|
||||
<svg
|
||||
className="w-14"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<ArrowCircleRightIcon className="w-14" />
|
||||
<div className="mt-2 font-extrabold">
|
||||
{intl.formatMessage(messages.seemore)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type {
|
||||
@@ -7,7 +8,6 @@ import type {
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from '../../../server/models/Search';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import PersonCard from '../PersonCard';
|
||||
import Slider from '../Slider';
|
||||
@@ -37,14 +37,13 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
|
||||
hideWhenEmpty = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${url}?page=${pageIndex + 1}&language=${locale}`;
|
||||
return `${url}?page=${pageIndex + 1}`;
|
||||
},
|
||||
{
|
||||
initialSize: 2,
|
||||
@@ -135,34 +134,19 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{linkUrl ? (
|
||||
<Link href={linkUrl}>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{title}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<div className="slider-header">
|
||||
{linkUrl ? (
|
||||
<Link href={linkUrl}>
|
||||
<a className="slider-title">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="slider-title">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey={sliderKey}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullcast: 'Full Cast',
|
||||
@@ -18,9 +17,8 @@ const messages = defineMessages({
|
||||
const MovieCast: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -45,7 +43,7 @@ const MovieCast: React.FC = () => {
|
||||
{intl.formatMessage(messages.fullcast)}
|
||||
</Header>
|
||||
</div>
|
||||
<ul className="cardList">
|
||||
<ul className="cards-vertical">
|
||||
{data?.credits.cast.map((person, index) => {
|
||||
return (
|
||||
<li key={`cast-${person.id}-${index}`}>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullcrew: 'Full Crew',
|
||||
@@ -18,9 +17,8 @@ const messages = defineMessages({
|
||||
const MovieCrew: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -45,7 +43,7 @@ const MovieCrew: React.FC = () => {
|
||||
{intl.formatMessage(messages.fullcrew)}
|
||||
</Header>
|
||||
</div>
|
||||
<ul className="cardList">
|
||||
<ul className="cards-vertical">
|
||||
{data?.credits.crew.map((person, index) => {
|
||||
return (
|
||||
<li key={`crew-${person.id}-${index}`}>
|
||||
|
||||
@@ -1,81 +1,42 @@
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR, { useSWRInfinite } from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
import Header from '../Common/Header';
|
||||
import ListView from '../Common/ListView';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
recommendationssubtext: 'If you liked {title}, you might also like…',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const MovieRecommendations: React.FC = () => {
|
||||
const settings = useSettings();
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
const { data: movieData } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/movie/${router.query.movieId}/recommendations?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>(
|
||||
`/api/v1/movie/${router.query.movieId}/recommendations`
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -84,14 +45,12 @@ const MovieRecommendations: React.FC = () => {
|
||||
<div className="mt-1 mb-5">
|
||||
<Header
|
||||
subtext={
|
||||
movieData && !movieError
|
||||
? intl.formatMessage(messages.recommendationssubtext, {
|
||||
title: movieData.title,
|
||||
})
|
||||
: ''
|
||||
<Link href={`/movie/${movieData?.id}`}>
|
||||
<a className="hover:underline">{movieData?.title}</a>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<FormattedMessage {...messages.recommendations} />
|
||||
{intl.formatMessage(messages.recommendations)}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
|
||||
@@ -1,81 +1,40 @@
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR, { useSWRInfinite } from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
import Header from '../Common/Header';
|
||||
import ListView from '../Common/ListView';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
similar: 'Similar Titles',
|
||||
similarsubtext: 'Other movies similar to {title}',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: MovieResult[];
|
||||
}
|
||||
|
||||
const MovieSimilar: React.FC = () => {
|
||||
const settings = useSettings();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData, error: movieError } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
const { data: movieData } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/movie/${router.query.movieId}/similar?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult>(`/api/v1/movie/${router.query.movieId}/similar`);
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as MovieResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -84,14 +43,12 @@ const MovieSimilar: React.FC = () => {
|
||||
<div className="mt-1 mb-5">
|
||||
<Header
|
||||
subtext={
|
||||
movieData && !movieError
|
||||
? intl.formatMessage(messages.similarsubtext, {
|
||||
title: movieData.title,
|
||||
})
|
||||
: undefined
|
||||
<Link href={`/movie/${movieData?.id}`}>
|
||||
<a className="hover:underline">{movieData?.title}</a>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<FormattedMessage {...messages.similar} />
|
||||
{intl.formatMessage(messages.similar)}
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
import React, { useState, useContext, useMemo } from 'react';
|
||||
import {
|
||||
defineMessages,
|
||||
FormattedNumber,
|
||||
FormattedDate,
|
||||
useIntl,
|
||||
} from 'react-intl';
|
||||
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
||||
import useSWR from 'swr';
|
||||
import { useRouter } from 'next/router';
|
||||
import Button from '../Common/Button';
|
||||
import Link from 'next/link';
|
||||
import Slider from '../Slider';
|
||||
import PersonCard from '../PersonCard';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
ArrowCircleRightIcon,
|
||||
CogIcon,
|
||||
FilmIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
DocumentRemoveIcon,
|
||||
ExternalLinkIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import RequestBlock from '../RequestBlock';
|
||||
import TmdbLogo from '../../assets/tmdb_logo.svg';
|
||||
import RTFresh from '../../assets/rt_fresh.svg';
|
||||
import RTRotten from '../../assets/rt_rotten.svg';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import { MediaServerType } from '../../../server/constants/server';
|
||||
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
|
||||
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
|
||||
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
import Error from '../../pages/_error';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
import { sortCrewPriority } from '../../utils/creditHelpers';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import RequestButton from '../RequestButton';
|
||||
import MediaSlider from '../MediaSlider';
|
||||
import ConfirmButton from '../Common/ConfirmButton';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import RTFresh from '../../assets/rt_fresh.svg';
|
||||
import RTRotten from '../../assets/rt_rotten.svg';
|
||||
import TmdbLogo from '../../assets/tmdb_logo.svg';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
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 { MediaServerType } from '../../../server/constants/server';
|
||||
import SlideOver from '../Common/SlideOver';
|
||||
import DownloadBlock from '../DownloadBlock';
|
||||
import ExternalLinkBlock from '../ExternalLinkBlock';
|
||||
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',
|
||||
userrating: 'User Rating',
|
||||
status: 'Status',
|
||||
revenue: 'Revenue',
|
||||
budget: 'Budget',
|
||||
watchtrailer: 'Watch Trailer',
|
||||
@@ -51,30 +60,25 @@ const messages = defineMessages({
|
||||
cast: 'Cast',
|
||||
recommendations: 'Recommendations',
|
||||
similar: 'Similar Titles',
|
||||
cancelrequest: 'Cancel Request',
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
pending: 'Pending',
|
||||
overviewunavailable: 'Overview unavailable.',
|
||||
manageModalTitle: 'Manage Movie',
|
||||
manageModalRequests: 'Requests',
|
||||
manageModalNoRequests: 'No Requests',
|
||||
manageModalClearMedia: 'Clear All Media Data',
|
||||
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 sync.',
|
||||
approve: 'Approve',
|
||||
decline: 'Decline',
|
||||
studio: 'Studio',
|
||||
'* 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',
|
||||
view: 'View',
|
||||
areyousure: 'Are you sure?',
|
||||
openradarr: 'Open Movie in Radarr',
|
||||
openradarr4k: 'Open Movie in 4K Radarr',
|
||||
downloadstatus: 'Download Status',
|
||||
play: 'Play on {mediaServerName}',
|
||||
play4k: 'Play 4K on {mediaServerName}',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark 4K as Available',
|
||||
mark4kavailable: 'Mark as Available in 4K',
|
||||
showmore: 'Show More',
|
||||
showless: 'Show Less',
|
||||
streamingproviders: 'Currently Streaming On',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -86,11 +90,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
const { user, hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { locale } = useLocale();
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const minStudios = 3;
|
||||
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<MovieDetailsType>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
|
||||
`/api/v1/movie/${router.query.movieId}`,
|
||||
{
|
||||
initialData: movie,
|
||||
}
|
||||
@@ -112,6 +118,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
||||
const mediaLinks: PlayButtonLink[] = [];
|
||||
|
||||
if (data.mediaInfo?.mediaUrl) {
|
||||
@@ -121,6 +128,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
? intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' })
|
||||
: intl.formatMessage(messages.play, { mediaServerName: 'Plex' }),
|
||||
url: data.mediaInfo?.mediaUrl,
|
||||
svg: <PlayIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +144,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
? intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' })
|
||||
: intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }),
|
||||
url: data.mediaInfo?.mediaUrl4k,
|
||||
svg: <PlayIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,6 +157,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
mediaLinks.push({
|
||||
text: intl.formatMessage(messages.watchtrailer),
|
||||
url: trailerUrl,
|
||||
svg: <FilmIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,17 +204,53 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
}
|
||||
|
||||
if (data.genres.length) {
|
||||
movieAttributes.push(data.genres.map((g) => g.name).join(', '));
|
||||
movieAttributes.push(
|
||||
data.genres
|
||||
.map((g) => (
|
||||
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
|
||||
<a className="hover:underline">{g.name}</a>
|
||||
</Link>
|
||||
))
|
||||
.reduce((prev, curr) => (
|
||||
<>
|
||||
{intl.formatMessage(globalMessages.delimitedlist, {
|
||||
a: prev,
|
||||
b: curr,
|
||||
})}
|
||||
</>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
const streamingProviders =
|
||||
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
|
||||
?.flatrate ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
|
||||
className="media-page"
|
||||
style={{
|
||||
height: 493,
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<PageTitle title={data.title} />
|
||||
<SlideOver
|
||||
show={showManager}
|
||||
@@ -215,7 +261,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
{((data?.mediaInfo?.downloadStatus ?? []).length > 0 ||
|
||||
(data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && (
|
||||
<>
|
||||
<h3 className="mb-2 text-xl">
|
||||
<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">
|
||||
@@ -253,18 +299,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<CheckCircleIcon />
|
||||
<span>{intl.formatMessage(messages.markavailable)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -278,18 +313,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
className="w-full sm:mb-0"
|
||||
buttonType="success"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<CheckCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.mark4kavailable)}
|
||||
</span>
|
||||
@@ -298,7 +322,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mb-2 text-xl">
|
||||
<h3 className="mb-2 text-xl font-bold">
|
||||
{intl.formatMessage(messages.manageModalRequests)}
|
||||
</h3>
|
||||
<div className="overflow-hidden bg-gray-600 rounded-md shadow">
|
||||
@@ -328,15 +352,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
className="block mb-2 last:mb-0"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.openradarr)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
@@ -348,15 +364,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button buttonType="ghost" className="w-full">
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||
</svg>
|
||||
<ExternalLinkIcon />
|
||||
<span>{intl.formatMessage(messages.openradarr4k)}</span>
|
||||
</Button>
|
||||
</a>
|
||||
@@ -367,56 +375,40 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
<div className="mt-8">
|
||||
<ConfirmButton
|
||||
onClick={() => deleteMedia()}
|
||||
confirmText={intl.formatMessage(messages.areyousure)}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
{intl.formatMessage(messages.manageModalClearMedia)}
|
||||
<DocumentRemoveIcon />
|
||||
<span>{intl.formatMessage(messages.manageModalClearMedia)}</span>
|
||||
</ConfirmButton>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.manageModalClearMediaWarning)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SlideOver>
|
||||
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
|
||||
<div className="lg:mr-4">
|
||||
<img
|
||||
<div className="media-header">
|
||||
<div className="media-poster">
|
||||
<CachedImage
|
||||
src={
|
||||
data.posterPath
|
||||
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2 space-x-2">
|
||||
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
|
||||
<span className="ml-2 lg:ml-0">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
|
||||
mediaUrl={data.mediaInfo?.mediaUrl}
|
||||
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status4k}
|
||||
is4k
|
||||
inProgress={(data.mediaInfo?.downloadStatus4k ?? []).length > 0}
|
||||
mediaUrl={data.mediaInfo?.mediaUrl}
|
||||
mediaUrl4k={
|
||||
data.mediaInfo?.mediaUrl4k &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
hasPermission(Permission.REQUEST_4K_MOVIE))
|
||||
? data.mediaInfo.mediaUrl4k
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<div className="media-title">
|
||||
<div className="media-status">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
mediaUrl={data.mediaInfo?.mediaUrl}
|
||||
/>
|
||||
{settings.currentSettings.movie4kEnabled &&
|
||||
hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
@@ -424,25 +416,25 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
type: 'or',
|
||||
}
|
||||
) && (
|
||||
<span>
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status4k}
|
||||
is4k
|
||||
inProgress={
|
||||
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||
}
|
||||
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
|
||||
/>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status4k}
|
||||
is4k
|
||||
inProgress={
|
||||
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
|
||||
}
|
||||
mediaUrl4k={data.mediaInfo?.mediaUrl4k}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-4xl">
|
||||
<h1>
|
||||
{data.title}{' '}
|
||||
{data.releaseDate && (
|
||||
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
|
||||
<span className="media-year">
|
||||
({data.releaseDate.slice(0, 4)})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<span className="mt-1 text-xs lg:text-base lg:mt-0">
|
||||
<span className="media-attributes">
|
||||
{movieAttributes.length > 0 &&
|
||||
movieAttributes
|
||||
.map((t, k) => <span key={k}>{t}</span>)
|
||||
@@ -453,113 +445,82 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
|
||||
<div className="mb-3 sm:mb-0">
|
||||
<PlayButton links={mediaLinks} />
|
||||
</div>
|
||||
<div className="mb-3 sm:mb-0">
|
||||
<RequestButton
|
||||
mediaType="movie"
|
||||
media={data.mediaInfo}
|
||||
tmdbId={data.id}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
</div>
|
||||
<div className="media-actions">
|
||||
<PlayButton links={mediaLinks} />
|
||||
<RequestButton
|
||||
mediaType="movie"
|
||||
media={data.mediaInfo}
|
||||
tmdbId={data.id}
|
||||
onUpdate={() => revalidate()}
|
||||
/>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="default"
|
||||
className="mb-3 ml-2 first:ml-0 sm:mb-0"
|
||||
className="ml-2 first:ml-0"
|
||||
onClick={() => setShowManager(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-5"
|
||||
style={{ height: 20 }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<CogIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
|
||||
<div className="flex-1 md:mr-8">
|
||||
<h2 className="text-xl md:text-2xl">
|
||||
{intl.formatMessage(messages.overview)}
|
||||
</h2>
|
||||
<p className="pt-2 text-sm md:text-base">
|
||||
<div className="media-overview">
|
||||
<div className="media-overview-left">
|
||||
{data.tagline && <div className="tagline">{data.tagline}</div>}
|
||||
<h2>{intl.formatMessage(messages.overview)}</h2>
|
||||
<p>
|
||||
{data.overview
|
||||
? data.overview
|
||||
: intl.formatMessage(messages.overviewunavailable)}
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
|
||||
{sortedCrew.slice(0, 6).map((person) => (
|
||||
<li
|
||||
className="flex flex-col col-span-1"
|
||||
key={`crew-${person.job}-${person.id}`}
|
||||
>
|
||||
<span className="font-bold">{person.job}</span>
|
||||
<Link href={`/person/${person.id}`}>
|
||||
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
|
||||
{person.name}
|
||||
{sortedCrew.length > 0 && (
|
||||
<>
|
||||
<ul className="media-crew">
|
||||
{sortedCrew.slice(0, 6).map((person) => (
|
||||
<li key={`crew-${person.job}-${person.id}`}>
|
||||
<span>{person.job}</span>
|
||||
<Link href={`/person/${person.id}`}>
|
||||
<a className="crew-name">{person.name}</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end mt-4">
|
||||
<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" />
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{sortedCrew.length > 0 && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<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>
|
||||
<svg
|
||||
className="inline-block w-5 h-5 ml-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full mt-8 md:w-80 md:mt-0">
|
||||
<div className="media-overview-right">
|
||||
{data.collection && (
|
||||
<div className="mb-6">
|
||||
<Link href={`/collection/${data.collection.id}`}>
|
||||
<a>
|
||||
<div
|
||||
className="relative z-0 transition duration-300 scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer transform-gpu group hover:scale-105"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
|
||||
<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="absolute inset-0 z-0">
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath}`}
|
||||
alt=""
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%)',
|
||||
}}
|
||||
/>
|
||||
</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>{data.collection.name}</div>
|
||||
<Button buttonSize="sm">
|
||||
{intl.formatMessage(messages.view)}
|
||||
{intl.formatMessage(globalMessages.view)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,165 +528,197 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
|
||||
<div className="media-facts">
|
||||
{(!!data.voteCount ||
|
||||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
|
||||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
|
||||
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<div className="media-ratings">
|
||||
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
<span className="media-rating">
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="mr-4 text-sm text-gray-400 last:mr-0">
|
||||
{ratingData.criticsScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
<span className="media-rating">
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6 mr-1" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6 mr-1" />
|
||||
)}
|
||||
</span>
|
||||
<span className="mr-4 text-sm text-gray-400 last:mr-0">
|
||||
{ratingData.audienceScore}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!!data.voteCount && (
|
||||
<>
|
||||
<span className="text-sm">
|
||||
<span className="media-rating">
|
||||
<TmdbLogo className="w-6 mr-2" />
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">
|
||||
{data.voteAverage}/10
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{data.originalTitle &&
|
||||
data.originalLanguage !== locale.slice(0, 2) && (
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.originaltitle)}</span>
|
||||
<span className="media-fact-value">{data.originalTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(globalMessages.status)}</span>
|
||||
<span className="media-fact-value">{data.status}</span>
|
||||
</div>
|
||||
{data.releaseDate && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.releasedate)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
<FormattedDate
|
||||
value={new Date(data.releaseDate)}
|
||||
year="numeric"
|
||||
month="long"
|
||||
day="numeric"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.status)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
{data.revenue > 0 && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.revenue)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
<FormattedNumber
|
||||
currency="USD"
|
||||
style="currency"
|
||||
value={data.revenue}
|
||||
/>
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.revenue)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatNumber(data.revenue, {
|
||||
currency: 'USD',
|
||||
style: 'currency',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.budget > 0 && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.budget)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
<FormattedNumber
|
||||
currency="USD"
|
||||
style="currency"
|
||||
value={data.budget}
|
||||
/>
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.budget)}</span>
|
||||
<span className="media-fact-value">
|
||||
{intl.formatNumber(data.budget, {
|
||||
currency: 'USD',
|
||||
style: 'currency',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.spokenLanguages.some(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
) && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.originallanguage)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
)?.name
|
||||
}
|
||||
{data.originalLanguage && (
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.originallanguage)}</span>
|
||||
<span className="media-fact-value">
|
||||
<Link
|
||||
href={`/discover/movies/language/${data.originalLanguage}`}
|
||||
>
|
||||
<a>
|
||||
{intl.formatDisplayName(data.originalLanguage, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ??
|
||||
data.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === data.originalLanguage
|
||||
)?.name}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{data.productionCompanies[0] && (
|
||||
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||
<span className="text-sm">
|
||||
{intl.formatMessage(messages.studio)}
|
||||
{data.productionCompanies.length > 0 && (
|
||||
<div className="media-fact">
|
||||
<span>
|
||||
{intl.formatMessage(messages.studio, {
|
||||
studioCount: data.productionCompanies.length,
|
||||
})}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-right text-gray-400">
|
||||
{data.productionCompanies[0]?.name}
|
||||
<span className="media-fact-value">
|
||||
{data.productionCompanies
|
||||
.slice(
|
||||
0,
|
||||
showAllStudios || showMoreStudios
|
||||
? data.productionCompanies.length
|
||||
: minStudios
|
||||
)
|
||||
.map((s) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/discover/movies/studio/${s.id}`}
|
||||
key={`studio-${s.id}`}
|
||||
>
|
||||
<a className="block">{s.name}</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{!showAllStudios && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMoreStudios(!showMoreStudios);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{intl.formatMessage(
|
||||
!showMoreStudios
|
||||
? messages.showmore
|
||||
: messages.showless
|
||||
)}
|
||||
{!showMoreStudios ? (
|
||||
<ChevronDoubleDownIcon className="w-4 h-4 ml-1" />
|
||||
) : (
|
||||
<ChevronDoubleUpIcon className="w-4 h-4 ml-1" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ExternalLinkBlock
|
||||
mediaType="movie"
|
||||
tmdbId={data.id}
|
||||
tvdbId={data.externalIds.tvdbId}
|
||||
imdbId={data.externalIds.imdbId}
|
||||
rtUrl={ratingData?.url}
|
||||
mediaUrl={data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k}
|
||||
/>
|
||||
{!!streamingProviders.length && (
|
||||
<div className="media-fact">
|
||||
<span>{intl.formatMessage(messages.streamingproviders)}</span>
|
||||
<span className="media-fact-value">
|
||||
{streamingProviders.map((p) => {
|
||||
return (
|
||||
<span className="block" key={`provider-${p.id}`}>
|
||||
{p.name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="media-fact">
|
||||
<ExternalLinkBlock
|
||||
mediaType="movie"
|
||||
tmdbId={data.id}
|
||||
tvdbId={data.externalIds.tvdbId}
|
||||
imdbId={data.externalIds.imdbId}
|
||||
rtUrl={ratingData?.url}
|
||||
mediaUrl={
|
||||
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.credits.cast.length > 0 && (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{intl.formatMessage(messages.cast)}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
|
||||
<a className="slider-title">
|
||||
<span>{intl.formatMessage(messages.cast)}</span>
|
||||
<ArrowCircleRightIcon />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey="cast"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { NotificationItem, hasNotificationType } from '..';
|
||||
import { hasNotificationType, NotificationItem } from '..';
|
||||
|
||||
interface NotificationTypeProps {
|
||||
option: NotificationItem;
|
||||
@@ -38,7 +38,7 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
|
||||
: currentTypes + option.value
|
||||
);
|
||||
}}
|
||||
defaultChecked={
|
||||
checked={
|
||||
hasNotificationType(option.value, currentTypes) ||
|
||||
(!!parent?.value &&
|
||||
hasNotificationType(parent.value, currentTypes))
|
||||
@@ -46,10 +46,14 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor={option.id} className="font-medium">
|
||||
{option.name}
|
||||
<label htmlFor={option.id} className="block">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{option.name}</span>
|
||||
<span className="font-normal text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<p className="text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(option.children ?? []).map((child) => (
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import React from 'react';
|
||||
import { sortBy } from 'lodash';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, User, useUser } from '../../hooks/useUser';
|
||||
import NotificationType from './NotificationType';
|
||||
|
||||
const messages = defineMessages({
|
||||
notificationTypes: 'Notification Types',
|
||||
mediarequested: 'Media Requested',
|
||||
mediarequestedDescription:
|
||||
'Sends a notification when media is requested and requires approval.',
|
||||
'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',
|
||||
mediaapprovedDescription:
|
||||
'Sends a notification when media is approved.\
|
||||
By default, automatically approved requests will not trigger notifications.',
|
||||
'Send notifications when media requests are manually approved.',
|
||||
usermediaapprovedDescription:
|
||||
'Get notified when your media requests are approved.',
|
||||
mediaAutoApproved: 'Media 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',
|
||||
mediaavailableDescription:
|
||||
'Sends a notification when media becomes available.',
|
||||
'Send notifications when media requests become available.',
|
||||
usermediaavailableDescription:
|
||||
'Get notified when your media requests become available.',
|
||||
mediafailed: 'Media Failed',
|
||||
mediafailedDescription:
|
||||
'Sends a notification when media fails to be added to Radarr or Sonarr.',
|
||||
'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',
|
||||
mediadeclinedDescription: 'Sends a notification when a request is declined.',
|
||||
mediadeclinedDescription:
|
||||
'Send notifications when media requests are declined.',
|
||||
usermediadeclinedDescription:
|
||||
'Get notified when your media requests are declined.',
|
||||
});
|
||||
|
||||
export const hasNotificationType = (
|
||||
@@ -26,91 +45,235 @@ export const hasNotificationType = (
|
||||
): boolean => {
|
||||
let total = 0;
|
||||
|
||||
// If we are not checking any notifications, bail out and return true
|
||||
if (types === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(types)) {
|
||||
// Combine all notification values into one
|
||||
total = types.reduce((a, v) => a + v, 0);
|
||||
} else {
|
||||
total = types;
|
||||
}
|
||||
|
||||
// Test notifications don't need to be enabled
|
||||
if (!(value & Notification.TEST_NOTIFICATION)) {
|
||||
value += Notification.TEST_NOTIFICATION;
|
||||
}
|
||||
|
||||
return !!(value & total);
|
||||
};
|
||||
|
||||
export enum Notification {
|
||||
NONE = 0,
|
||||
MEDIA_PENDING = 2,
|
||||
MEDIA_APPROVED = 4,
|
||||
MEDIA_AVAILABLE = 8,
|
||||
MEDIA_FAILED = 16,
|
||||
TEST_NOTIFICATION = 32,
|
||||
MEDIA_DECLINED = 64,
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
}
|
||||
|
||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||
.filter((v) => !isNaN(Number(v)))
|
||||
.reduce((a, v) => a + Number(v), 0);
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
value: Notification;
|
||||
hasNotifyUser?: boolean;
|
||||
children?: NotificationItem[];
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationTypeSelectorProps {
|
||||
user?: User;
|
||||
enabledTypes?: number;
|
||||
currentTypes: number;
|
||||
onUpdate: (newTypes: number) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
|
||||
user,
|
||||
enabledTypes = ALL_NOTIFICATIONS,
|
||||
currentTypes,
|
||||
onUpdate,
|
||||
error,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { hasPermission } = useUser({ id: user?.id });
|
||||
const [allowedTypes, setAllowedTypes] = useState(enabledTypes);
|
||||
|
||||
const types: NotificationItem[] = [
|
||||
{
|
||||
id: 'media-requested',
|
||||
name: intl.formatMessage(messages.mediarequested),
|
||||
description: intl.formatMessage(messages.mediarequestedDescription),
|
||||
value: Notification.MEDIA_PENDING,
|
||||
},
|
||||
{
|
||||
id: 'media-approved',
|
||||
name: intl.formatMessage(messages.mediaapproved),
|
||||
description: intl.formatMessage(messages.mediaapprovedDescription),
|
||||
value: Notification.MEDIA_APPROVED,
|
||||
},
|
||||
{
|
||||
id: 'media-declined',
|
||||
name: intl.formatMessage(messages.mediadeclined),
|
||||
description: intl.formatMessage(messages.mediadeclinedDescription),
|
||||
value: Notification.MEDIA_DECLINED,
|
||||
},
|
||||
{
|
||||
id: 'media-available',
|
||||
name: intl.formatMessage(messages.mediaavailable),
|
||||
description: intl.formatMessage(messages.mediaavailableDescription),
|
||||
value: Notification.MEDIA_AVAILABLE,
|
||||
},
|
||||
{
|
||||
id: 'media-failed',
|
||||
name: intl.formatMessage(messages.mediafailed),
|
||||
description: intl.formatMessage(messages.mediafailedDescription),
|
||||
value: Notification.MEDIA_FAILED,
|
||||
},
|
||||
];
|
||||
const availableTypes = useMemo(() => {
|
||||
const allRequestsAutoApproved =
|
||||
user &&
|
||||
// Has Manage Requests perm, which grants all Auto-Approve perms
|
||||
(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
// Cannot submit requests of any type
|
||||
!hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
Permission.REQUEST_MOVIE,
|
||||
Permission.REQUEST_TV,
|
||||
Permission.REQUEST_4K,
|
||||
Permission.REQUEST_4K_MOVIE,
|
||||
Permission.REQUEST_4K_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
) ||
|
||||
// Cannot submit non-4K movie requests OR has Auto-Approve perms for non-4K movies
|
||||
((!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
hasPermission(
|
||||
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE],
|
||||
{ type: 'or' }
|
||||
)) &&
|
||||
// Cannot submit non-4K series requests OR has Auto-Approve perms for non-4K series
|
||||
(!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
hasPermission(
|
||||
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_TV],
|
||||
{ type: 'or' }
|
||||
)) &&
|
||||
// Cannot submit 4K movie requests OR has Auto-Approve perms for 4K movies
|
||||
(!settings.currentSettings.movie4kEnabled ||
|
||||
!hasPermission(
|
||||
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
{ type: 'or' }
|
||||
) ||
|
||||
hasPermission(
|
||||
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_MOVIE],
|
||||
{ type: 'or' }
|
||||
)) &&
|
||||
// Cannot submit 4K series requests OR has Auto-Approve perms for 4K series
|
||||
(!settings.currentSettings.series4kEnabled ||
|
||||
!hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
}) ||
|
||||
hasPermission(
|
||||
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_TV],
|
||||
{ type: 'or' }
|
||||
))));
|
||||
|
||||
const types: NotificationItem[] = [
|
||||
{
|
||||
id: 'media-requested',
|
||||
name: intl.formatMessage(messages.mediarequested),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.usermediarequestedDescription
|
||||
: messages.mediarequestedDescription
|
||||
),
|
||||
value: Notification.MEDIA_PENDING,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
},
|
||||
{
|
||||
id: 'media-auto-approved',
|
||||
name: intl.formatMessage(messages.mediaAutoApproved),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.usermediaAutoApprovedDescription
|
||||
: messages.mediaAutoApprovedDescription
|
||||
),
|
||||
value: Notification.MEDIA_AUTO_APPROVED,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
},
|
||||
{
|
||||
id: 'media-approved',
|
||||
name: intl.formatMessage(messages.mediaapproved),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.usermediaapprovedDescription
|
||||
: messages.mediaapprovedDescription
|
||||
),
|
||||
value: Notification.MEDIA_APPROVED,
|
||||
hasNotifyUser: true,
|
||||
hidden: allRequestsAutoApproved,
|
||||
},
|
||||
{
|
||||
id: 'media-declined',
|
||||
name: intl.formatMessage(messages.mediadeclined),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.usermediadeclinedDescription
|
||||
: messages.mediadeclinedDescription
|
||||
),
|
||||
value: Notification.MEDIA_DECLINED,
|
||||
hasNotifyUser: true,
|
||||
hidden: allRequestsAutoApproved,
|
||||
},
|
||||
{
|
||||
id: 'media-available',
|
||||
name: intl.formatMessage(messages.mediaavailable),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.usermediaavailableDescription
|
||||
: messages.mediaavailableDescription
|
||||
),
|
||||
value: Notification.MEDIA_AVAILABLE,
|
||||
hasNotifyUser: true,
|
||||
},
|
||||
{
|
||||
id: 'media-failed',
|
||||
name: intl.formatMessage(messages.mediafailed),
|
||||
description: intl.formatMessage(
|
||||
user
|
||||
? messages.usermediafailedDescription
|
||||
: messages.mediafailedDescription
|
||||
),
|
||||
value: Notification.MEDIA_FAILED,
|
||||
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
|
||||
},
|
||||
];
|
||||
|
||||
const filteredTypes = types.filter(
|
||||
(type) => !type.hidden && hasNotificationType(type.value, enabledTypes)
|
||||
);
|
||||
|
||||
const newAllowedTypes = filteredTypes.reduce((a, v) => a + v.value, 0);
|
||||
if (newAllowedTypes !== allowedTypes) {
|
||||
setAllowedTypes(newAllowedTypes);
|
||||
}
|
||||
|
||||
return user
|
||||
? sortBy(filteredTypes, 'hasNotifyUser', 'DESC')
|
||||
: filteredTypes;
|
||||
}, [user, hasPermission, settings, intl, allowedTypes, enabledTypes]);
|
||||
|
||||
if (!availableTypes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{types.map((type) => (
|
||||
<NotificationType
|
||||
key={`notification-type-${type.id}`}
|
||||
option={type}
|
||||
currentTypes={currentTypes}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<div role="group" aria-labelledby="group-label" className="form-group">
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationTypes)}
|
||||
{!user && <span className="label-required">*</span>}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
{availableTypes.map((type) => (
|
||||
<NotificationType
|
||||
key={`notification-type-${type.id}`}
|
||||
option={type}
|
||||
currentTypes={currentTypes}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
183
src/components/PWAHeader/index.tsx
Normal file
183
src/components/PWAHeader/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PWAHeaderProps {
|
||||
applicationTitle?: string;
|
||||
}
|
||||
|
||||
const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
|
||||
return (
|
||||
<>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2048-2732.jpg"
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2732-2048.jpg"
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1668-2388.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2388-1668.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1536-2048.jpg"
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2048-1536.jpg"
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1668-2224.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2224-1668.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1620-2160.jpg"
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2160-1620.jpg"
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1284-2778.jpg"
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2778-1284.jpg"
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1170-2532.jpg"
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2532-1170.jpg"
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1125-2436.jpg"
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2436-1125.jpg"
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1242-2688.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2688-1242.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-828-1792.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1792-828.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1242-2208.jpg"
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2208-1242.jpg"
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-750-1334.jpg"
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1334-750.jpg"
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-640-1136.jpg"
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1136-640.jpg"
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/site.webmanifest"
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
<meta name="application-name" content={applicationTitle ?? 'Overseerr'} />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content={applicationTitle ?? 'Overseerr'}
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Request and Media Discovery Application"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PWAHeader;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PermissionOption, { PermissionItem } from '../PermissionOption';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Permission, User } from '../../hooks/useUser';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import PermissionOption, { PermissionItem } from '../PermissionOption';
|
||||
|
||||
export const messages = defineMessages({
|
||||
admin: 'Admin',
|
||||
@@ -9,47 +9,46 @@ export const messages = defineMessages({
|
||||
'Full administrator access. Bypasses all other permission checks.',
|
||||
users: 'Manage Users',
|
||||
usersDescription:
|
||||
'Grants permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
|
||||
'Grant permission to manage Overseerr users. Users with this permission cannot modify users with or grant the Admin privilege.',
|
||||
settings: 'Manage Settings',
|
||||
settingsDescription:
|
||||
'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.',
|
||||
'Grant permission to modify Overseerr settings. A user must have this permission to grant it to others.',
|
||||
managerequests: 'Manage Requests',
|
||||
managerequestsDescription:
|
||||
'Grants permission to manage Overseerr requests. This includes approving and denying requests. All requests made by a user with this permission will be automatically approved regardless of whether or not they have Auto-Approve permissions.',
|
||||
'Grant permission to manage Overseerr requests. All requests made by a user with this permission will be automatically approved.',
|
||||
request: 'Request',
|
||||
requestDescription: 'Grants permission to request movies and series.',
|
||||
vote: 'Vote',
|
||||
voteDescription:
|
||||
'Grants permission to vote on requests (voting not yet implemented).',
|
||||
requestDescription: 'Grant permission to request non-4K media.',
|
||||
requestMovies: 'Request Movies',
|
||||
requestMoviesDescription: 'Grant permission to request non-4K movies.',
|
||||
requestTv: 'Request Series',
|
||||
requestTvDescription: 'Grant permission to request non-4K series.',
|
||||
autoapprove: 'Auto-Approve',
|
||||
autoapproveDescription:
|
||||
'Grants automatic approval for all non-4K requests made by this user.',
|
||||
autoapproveDescription: 'Grant automatic approval for all non-4K requests.',
|
||||
autoapproveMovies: 'Auto-Approve Movies',
|
||||
autoapproveMoviesDescription:
|
||||
'Grants automatic approval for non-4K movie requests made by this user.',
|
||||
'Grant automatic approval for non-4K movie requests.',
|
||||
autoapproveSeries: 'Auto-Approve Series',
|
||||
autoapproveSeriesDescription:
|
||||
'Grants automatic approval for non-4K series requests made by this user.',
|
||||
'Grant automatic approval for non-4K series requests.',
|
||||
autoapprove4k: 'Auto-Approve 4K',
|
||||
autoapprove4kDescription:
|
||||
'Grants automatic approval for all 4K requests made by this user.',
|
||||
autoapprove4kDescription: 'Grant automatic approval for all 4K requests.',
|
||||
autoapprove4kMovies: 'Auto-Approve 4K Movies',
|
||||
autoapprove4kMoviesDescription:
|
||||
'Grants automatic approval for 4K movie requests made by this user.',
|
||||
'Grant automatic approval for 4K movie requests.',
|
||||
autoapprove4kSeries: 'Auto-Approve 4K Series',
|
||||
autoapprove4kSeriesDescription:
|
||||
'Grants automatic approval for 4K series requests made by this user.',
|
||||
'Grant automatic approval for 4K series requests.',
|
||||
request4k: 'Request 4K',
|
||||
request4kDescription: 'Grants permission to request 4K movies and series.',
|
||||
request4kDescription: 'Grant permission to request 4K media.',
|
||||
request4kMovies: 'Request 4K Movies',
|
||||
request4kMoviesDescription: 'Grants permission to request 4K movies.',
|
||||
request4kMoviesDescription: 'Grant permission to request 4K movies.',
|
||||
request4kTv: 'Request 4K Series',
|
||||
request4kTvDescription: 'Grants permission to request 4K Series.',
|
||||
request4kTvDescription: 'Grant permission to request 4K series.',
|
||||
advancedrequest: 'Advanced Requests',
|
||||
advancedrequestDescription:
|
||||
'Grants permission to use advanced request options (e.g., changing servers, profiles, or paths).',
|
||||
'Grant permission to use advanced request options.',
|
||||
viewrequests: 'View Requests',
|
||||
viewrequestsDescription: "Grants permission to view other users' requests.",
|
||||
viewrequestsDescription: "Grant permission to view other users' requests.",
|
||||
});
|
||||
|
||||
interface PermissionEditProps {
|
||||
@@ -111,27 +110,18 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
name: intl.formatMessage(messages.request),
|
||||
description: intl.formatMessage(messages.requestDescription),
|
||||
permission: Permission.REQUEST,
|
||||
},
|
||||
{
|
||||
id: 'request4k',
|
||||
name: intl.formatMessage(messages.request4k),
|
||||
description: intl.formatMessage(messages.request4kDescription),
|
||||
permission: Permission.REQUEST_4K,
|
||||
requires: [{ permissions: [Permission.REQUEST] }],
|
||||
children: [
|
||||
{
|
||||
id: 'request4k-movies',
|
||||
name: intl.formatMessage(messages.request4kMovies),
|
||||
description: intl.formatMessage(messages.request4kMoviesDescription),
|
||||
permission: Permission.REQUEST_4K_MOVIE,
|
||||
requires: [{ permissions: [Permission.REQUEST] }],
|
||||
id: 'request-movies',
|
||||
name: intl.formatMessage(messages.requestMovies),
|
||||
description: intl.formatMessage(messages.requestMoviesDescription),
|
||||
permission: Permission.REQUEST_MOVIE,
|
||||
},
|
||||
{
|
||||
id: 'request4k-tv',
|
||||
name: intl.formatMessage(messages.request4kTv),
|
||||
description: intl.formatMessage(messages.request4kTvDescription),
|
||||
permission: Permission.REQUEST_4K_TV,
|
||||
requires: [{ permissions: [Permission.REQUEST] }],
|
||||
id: 'request-tv',
|
||||
name: intl.formatMessage(messages.requestTv),
|
||||
description: intl.formatMessage(messages.requestTvDescription),
|
||||
permission: Permission.REQUEST_TV,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -149,7 +139,12 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
messages.autoapproveMoviesDescription
|
||||
),
|
||||
permission: Permission.AUTO_APPROVE_MOVIE,
|
||||
requires: [{ permissions: [Permission.REQUEST] }],
|
||||
requires: [
|
||||
{
|
||||
permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE],
|
||||
type: 'or',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'autoapprovetv',
|
||||
@@ -158,7 +153,32 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
messages.autoapproveSeriesDescription
|
||||
),
|
||||
permission: Permission.AUTO_APPROVE_TV,
|
||||
requires: [{ permissions: [Permission.REQUEST] }],
|
||||
requires: [
|
||||
{
|
||||
permissions: [Permission.REQUEST, Permission.REQUEST_TV],
|
||||
type: 'or',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'request4k',
|
||||
name: intl.formatMessage(messages.request4k),
|
||||
description: intl.formatMessage(messages.request4kDescription),
|
||||
permission: Permission.REQUEST_4K,
|
||||
children: [
|
||||
{
|
||||
id: 'request4k-movies',
|
||||
name: intl.formatMessage(messages.request4kMovies),
|
||||
description: intl.formatMessage(messages.request4kMoviesDescription),
|
||||
permission: Permission.REQUEST_4K_MOVIE,
|
||||
},
|
||||
{
|
||||
id: 'request4k-tv',
|
||||
name: intl.formatMessage(messages.request4kTv),
|
||||
description: intl.formatMessage(messages.request4kTvDescription),
|
||||
permission: Permission.REQUEST_4K_TV,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -169,8 +189,7 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
permission: Permission.AUTO_APPROVE_4K,
|
||||
requires: [
|
||||
{
|
||||
permissions: [Permission.REQUEST, Permission.REQUEST_4K],
|
||||
type: 'and',
|
||||
permissions: [Permission.REQUEST_4K],
|
||||
},
|
||||
],
|
||||
children: [
|
||||
@@ -182,9 +201,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
),
|
||||
permission: Permission.AUTO_APPROVE_4K_MOVIE,
|
||||
requires: [
|
||||
{
|
||||
permissions: [Permission.REQUEST],
|
||||
},
|
||||
{
|
||||
permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
|
||||
type: 'or',
|
||||
@@ -199,9 +215,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
|
||||
),
|
||||
permission: Permission.AUTO_APPROVE_4K_TV,
|
||||
requires: [
|
||||
{
|
||||
permissions: [Permission.REQUEST],
|
||||
},
|
||||
{
|
||||
permissions: [Permission.REQUEST_4K, Permission.REQUEST_4K_TV],
|
||||
type: 'or',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { hasPermission } from '../../../server/lib/permissions';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, User } from '../../hooks/useUser';
|
||||
|
||||
export interface PermissionItem {
|
||||
@@ -33,6 +34,8 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
onUpdate,
|
||||
parent,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
|
||||
const autoApprovePermissions = [
|
||||
Permission.AUTO_APPROVE,
|
||||
Permission.AUTO_APPROVE_MOVIE,
|
||||
@@ -42,34 +45,70 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
Permission.AUTO_APPROVE_4K_TV,
|
||||
];
|
||||
|
||||
let disabled = false;
|
||||
let checked = hasPermission(option.permission, currentPermission);
|
||||
|
||||
if (
|
||||
// Permissions for user ID 1 (Plex server owner) cannot be changed
|
||||
(currentUser && currentUser.id === 1) ||
|
||||
// Admin permission automatically bypasses/grants all other permissions
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
// Manage Requests permission automatically grants all Auto-Approve permissions
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||
// Selecting a parent permission automatically selects all children
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission))
|
||||
) {
|
||||
disabled = true;
|
||||
checked = true;
|
||||
}
|
||||
|
||||
if (
|
||||
// Non-Admin users cannot modify the Admin permission
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
// Users without the Manage Settings permission cannot modify/grant that permission
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.MANAGE_SETTINGS, actingUser.permissions) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS)
|
||||
) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
if (
|
||||
// Some permissions are dependent on others; check requirements are fulfilled
|
||||
(option.requires &&
|
||||
!option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
)) ||
|
||||
// Request 4K and Auto-Approve 4K require both 4K movie & 4K series requests to be enabled
|
||||
((option.permission === Permission.REQUEST_4K ||
|
||||
option.permission === Permission.AUTO_APPROVE_4K) &&
|
||||
(!settings.currentSettings.movie4kEnabled ||
|
||||
!settings.currentSettings.series4kEnabled)) ||
|
||||
// Request 4K Movie and Auto-Approve 4K Movie require 4K movie requests to be enabled
|
||||
((option.permission === Permission.REQUEST_4K_MOVIE ||
|
||||
option.permission === Permission.AUTO_APPROVE_4K_MOVIE) &&
|
||||
!settings.currentSettings.movie4kEnabled) ||
|
||||
// Request 4K Series and Auto-Approve 4K Series require 4K series requests to be enabled
|
||||
((option.permission === Permission.REQUEST_4K_TV ||
|
||||
option.permission === Permission.AUTO_APPROVE_4K_TV) &&
|
||||
!settings.currentSettings.series4kEnabled)
|
||||
) {
|
||||
disabled = true;
|
||||
checked = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative flex items-start first:mt-0 mt-4 ${
|
||||
(currentUser && currentUser.id === 1) ||
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
(actingUser &&
|
||||
!hasPermission(
|
||||
Permission.MANAGE_SETTINGS,
|
||||
actingUser.permissions
|
||||
) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS) ||
|
||||
(option.requires &&
|
||||
!option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
))
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-6">
|
||||
@@ -77,30 +116,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
id={option.id}
|
||||
name="permissions"
|
||||
type="checkbox"
|
||||
disabled={
|
||||
(currentUser && currentUser.id === 1) ||
|
||||
(option.permission !== Permission.ADMIN &&
|
||||
hasPermission(Permission.ADMIN, currentPermission)) ||
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS, currentPermission)) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(actingUser &&
|
||||
!hasPermission(Permission.ADMIN, actingUser.permissions) &&
|
||||
option.permission === Permission.ADMIN) ||
|
||||
(actingUser &&
|
||||
!hasPermission(
|
||||
Permission.MANAGE_SETTINGS,
|
||||
actingUser.permissions
|
||||
) &&
|
||||
option.permission === Permission.MANAGE_SETTINGS) ||
|
||||
(option.requires &&
|
||||
!option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
))
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
onUpdate(
|
||||
hasPermission(option.permission, currentPermission)
|
||||
@@ -108,29 +124,16 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
|
||||
: currentPermission + option.permission
|
||||
);
|
||||
}}
|
||||
checked={
|
||||
(hasPermission(option.permission, currentPermission) ||
|
||||
(!!parent?.permission &&
|
||||
hasPermission(parent.permission, currentPermission)) ||
|
||||
(autoApprovePermissions.includes(option.permission) &&
|
||||
hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
currentPermission
|
||||
))) &&
|
||||
(!option.requires ||
|
||||
option.requires.every((requirement) =>
|
||||
hasPermission(requirement.permissions, currentPermission, {
|
||||
type: requirement.type ?? 'and',
|
||||
})
|
||||
))
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor={option.id} className="block font-medium">
|
||||
<label htmlFor={option.id} className="block">
|
||||
<div className="flex flex-col">
|
||||
<span>{option.name}</span>
|
||||
<span className="text-gray-500">{option.description}</span>
|
||||
<span className="font-medium text-white">{option.name}</span>
|
||||
<span className="font-normal text-gray-400">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UserCircleIcon } from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
|
||||
interface PersonCardProps {
|
||||
personId: number;
|
||||
@@ -37,35 +39,31 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
<div
|
||||
className={`relative ${
|
||||
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
|
||||
} rounded-lg text-white shadow-lg transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
||||
isHovered ? 'bg-gray-600 scale-105' : 'bg-gray-700 scale-100'
|
||||
} rounded-xl text-white shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
|
||||
isHovered
|
||||
? 'bg-gray-700 scale-105 ring-gray-500'
|
||||
: 'bg-gray-800 scale-100 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">
|
||||
{profilePath ? (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||
className="object-cover w-3/4 h-full bg-center bg-cover rounded-full"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="h-full"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-6-3a2 2 0 11-4 0 2 2 0 014 0zm-2 4a5 5 0 00-4.546 2.916A5.986 5.986 0 0010 16a5.986 5.986 0 004.546-2.084A5 5 0 0010 11z"
|
||||
clipRule="evenodd"
|
||||
<div className="relative w-3/4 h-full overflow-hidden rounded-full ring-1 ring-gray-700">
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${profilePath}`}
|
||||
alt=""
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<UserCircleIcon className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full text-center truncate">{name}</div>
|
||||
<div className="w-full font-bold text-center truncate">
|
||||
{name}
|
||||
</div>
|
||||
{subName && (
|
||||
<div
|
||||
className="overflow-hidden text-sm text-center text-gray-300 whitespace-normal"
|
||||
@@ -79,7 +77,11 @@ const PersonCard: React.FC<PersonCardProps> = ({
|
||||
{subName}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-12 rounded-b-lg bg-gradient-to-t from-gray-700" />
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-12 rounded-b-xl bg-gradient-to-t ${
|
||||
isHovered ? 'from-gray-800' : 'from-gray-900'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import TruncateMarkup from 'react-truncate-markup';
|
||||
import useSWR from 'swr';
|
||||
import type { PersonDetail } from '../../../server/models/Person';
|
||||
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
|
||||
import Error from '../../pages/_error';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import TitleCard from '../TitleCard';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import type { PersonDetail } from '../../../server/models/Person';
|
||||
import Ellipsis from '../../assets/ellipsis.svg';
|
||||
import { groupBy } from 'lodash';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Error from '../../pages/_error';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import TitleCard from '../TitleCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
appearsin: 'Appears in',
|
||||
crewmember: 'Crew Member',
|
||||
birthdate: 'Born {birthdate}',
|
||||
lifespan: '{birthdate} – {deathdate}',
|
||||
alsoknownas: 'Also Known As: {names}',
|
||||
appearsin: 'Appearances',
|
||||
crewmember: 'Crew',
|
||||
ascharacter: 'as {character}',
|
||||
nobiography: 'No biography available.',
|
||||
});
|
||||
|
||||
const PersonDetails: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<PersonDetail>(
|
||||
`/api/v1/person/${router.query.personId}`
|
||||
@@ -34,7 +36,7 @@ const PersonDetails: React.FC = () => {
|
||||
data: combinedCredits,
|
||||
error: errorCombinedCredits,
|
||||
} = useSWR<PersonCombinedCreditsResponse>(
|
||||
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
|
||||
`/api/v1/person/${router.query.personId}/combined_credits`
|
||||
);
|
||||
|
||||
const sortedCast = useMemo(() => {
|
||||
@@ -81,18 +83,51 @@ const PersonDetails: React.FC = () => {
|
||||
return <Error statusCode={404} />;
|
||||
}
|
||||
|
||||
const personAttributes: string[] = [];
|
||||
|
||||
if (data.birthday) {
|
||||
if (data.deathday) {
|
||||
personAttributes.push(
|
||||
intl.formatMessage(messages.lifespan, {
|
||||
birthdate: intl.formatDate(data.birthday, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
deathdate: intl.formatDate(data.deathday, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
personAttributes.push(
|
||||
intl.formatMessage(messages.birthdate, {
|
||||
birthdate: intl.formatDate(data.birthday, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.placeOfBirth) {
|
||||
personAttributes.push(data.placeOfBirth);
|
||||
}
|
||||
|
||||
const isLoading = !combinedCredits && !errorCombinedCredits;
|
||||
|
||||
const cast = (sortedCast ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{intl.formatMessage(messages.appearsin)}</span>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.appearsin)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="cardList">
|
||||
<ul className="cards-vertical">
|
||||
{sortedCast?.map((media, index) => {
|
||||
return (
|
||||
<li key={`list-cast-item-${media.id}-${index}`}>
|
||||
@@ -112,7 +147,7 @@ const PersonDetails: React.FC = () => {
|
||||
canExpand
|
||||
/>
|
||||
{media.character && (
|
||||
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
|
||||
<div className="w-full mt-2 text-xs text-center text-gray-300 truncate">
|
||||
{intl.formatMessage(messages.ascharacter, {
|
||||
character: media.character,
|
||||
})}
|
||||
@@ -127,14 +162,12 @@ const PersonDetails: React.FC = () => {
|
||||
|
||||
const crew = (sortedCrew ?? []).length > 0 && (
|
||||
<>
|
||||
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{intl.formatMessage(messages.crewmember)}</span>
|
||||
</div>
|
||||
<div className="slider-header">
|
||||
<div className="slider-title">
|
||||
<span>{intl.formatMessage(messages.crewmember)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="cardList">
|
||||
<ul className="cards-vertical">
|
||||
{sortedCrew?.map((media, index) => {
|
||||
return (
|
||||
<li key={`list-crew-item-${media.id}-${index}`}>
|
||||
@@ -154,7 +187,7 @@ const PersonDetails: React.FC = () => {
|
||||
canExpand
|
||||
/>
|
||||
{media.job && (
|
||||
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
|
||||
<div className="w-full mt-2 text-xs text-center text-gray-300 truncate">
|
||||
{media.job}
|
||||
</div>
|
||||
)}
|
||||
@@ -169,52 +202,71 @@ const PersonDetails: React.FC = () => {
|
||||
<>
|
||||
<PageTitle title={data.name} />
|
||||
{(sortedCrew || sortedCast) && (
|
||||
<div className="absolute left-0 right-0 z-0 -top-16 h-96">
|
||||
<div className="absolute top-0 left-0 right-0 z-0 h-96">
|
||||
<ImageFader
|
||||
isDarker
|
||||
backgroundImages={[...(sortedCast ?? []), ...(sortedCrew ?? [])]
|
||||
.filter((media) => media.backdropPath)
|
||||
.map(
|
||||
(media) =>
|
||||
`//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
|
||||
`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
|
||||
)
|
||||
.slice(0, 6)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative z-10 flex flex-col items-center mt-4 mb-8 md:flex-row md:items-start">
|
||||
<div
|
||||
className={`relative z-10 flex flex-col items-center mt-4 mb-8 lg:flex-row ${
|
||||
data.biography ? 'lg:items-start' : ''
|
||||
}`}
|
||||
>
|
||||
{data.profilePath && (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url(https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath})`,
|
||||
}}
|
||||
className="flex-shrink-0 mb-6 mr-0 bg-center bg-cover rounded-full w-36 h-36 md:w-44 md:h-44 md:mb-0 md:mr-6"
|
||||
/>
|
||||
)}
|
||||
<div className="text-center text-gray-300 md:text-left">
|
||||
<h1 className="mb-4 text-3xl text-white md:text-4xl">{data.name}</h1>
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
||||
<div
|
||||
className="outline-none group ring-0"
|
||||
onClick={() => setShowBio((show) => !show)}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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" />
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{data.biography
|
||||
? data.biography
|
||||
: intl.formatMessage(messages.nobiography)}
|
||||
</div>
|
||||
</TruncateMarkup>
|
||||
</div>
|
||||
<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">
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.profilePath}`}
|
||||
alt=""
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center text-gray-300 lg:text-left">
|
||||
<h1 className="text-3xl text-white lg:text-4xl">{data.name}</h1>
|
||||
<div className="mt-1 mb-2 space-y-1 text-xs text-white sm:text-sm lg:text-base">
|
||||
<div>{personAttributes.join(' | ')}</div>
|
||||
{(data.alsoKnownAs ?? []).length > 0 && (
|
||||
<div>
|
||||
{intl.formatMessage(messages.alsoknownas, {
|
||||
names: (data.alsoKnownAs ?? []).reduce((prev, curr) =>
|
||||
intl.formatMessage(globalMessages.delimitedlist, {
|
||||
a: prev,
|
||||
b: curr,
|
||||
})
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{data.biography && (
|
||||
<div className="relative text-left">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
||||
<div
|
||||
className="outline-none group ring-0"
|
||||
onClick={() => setShowBio((show) => !show)}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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" />
|
||||
}
|
||||
>
|
||||
<p className="pt-2 text-sm lg:text-base">{data.biography}</p>
|
||||
</TruncateMarkup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { LoginIcon } from '@heroicons/react/outline';
|
||||
import React, { useState } from 'react';
|
||||
import PlexOAuth from '../../utils/plex';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import PlexOAuth from '../../utils/plex';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinwithplex: 'Sign In',
|
||||
loading: 'Loading…',
|
||||
signingin: 'Signing in…',
|
||||
signingin: 'Signing In…',
|
||||
});
|
||||
|
||||
const plexOAuth = new PlexOAuth();
|
||||
@@ -48,11 +49,14 @@ const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
|
||||
disabled={loading || isProcessing}
|
||||
className="plex-button"
|
||||
>
|
||||
{loading
|
||||
? intl.formatMessage(messages.loading)
|
||||
: isProcessing
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signinwithplex)}
|
||||
<LoginIcon />
|
||||
<span>
|
||||
{loading
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: isProcessing
|
||||
? intl.formatMessage(messages.signingin)
|
||||
: intl.formatMessage(messages.signinwithplex)}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
104
src/components/QuotaSelector/index.tsx
Normal file
104
src/components/QuotaSelector/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
movieRequests:
|
||||
'{quotaLimit} <quotaUnits>{movies} per {quotaDays} {days}</quotaUnits>',
|
||||
tvRequests:
|
||||
'{quotaLimit} <quotaUnits>{seasons} per {quotaDays} {days}</quotaUnits>',
|
||||
movies: '{count, plural, one {movie} other {movies}}',
|
||||
seasons: '{count, plural, one {season} other {seasons}}',
|
||||
days: '{count, plural, one {day} other {days}}',
|
||||
unlimited: 'Unlimited',
|
||||
});
|
||||
|
||||
interface QuotaSelectorProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
defaultDays?: number;
|
||||
defaultLimit?: number;
|
||||
dayOverride?: number;
|
||||
limitOverride?: number;
|
||||
dayFieldName: string;
|
||||
limitFieldName: string;
|
||||
isDisabled?: boolean;
|
||||
onChange: (fieldName: string, value: number) => void;
|
||||
}
|
||||
|
||||
const QuotaSelector: React.FC<QuotaSelectorProps> = ({
|
||||
mediaType,
|
||||
dayFieldName,
|
||||
limitFieldName,
|
||||
defaultDays = 7,
|
||||
defaultLimit = 0,
|
||||
dayOverride,
|
||||
limitOverride,
|
||||
isDisabled = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const initialDays = defaultDays ?? 7;
|
||||
const initialLimit = defaultLimit ?? 0;
|
||||
const [quotaDays, setQuotaDays] = useState(initialDays);
|
||||
const [quotaLimit, setQuotaLimit] = useState(initialLimit);
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
onChange(dayFieldName, quotaDays);
|
||||
}, [dayFieldName, onChange, quotaDays]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(limitFieldName, quotaLimit);
|
||||
}, [limitFieldName, onChange, quotaLimit]);
|
||||
|
||||
return (
|
||||
<div className={`${isDisabled ? 'opacity-50' : ''}`}>
|
||||
{intl.formatMessage(
|
||||
mediaType === 'movie' ? messages.movieRequests : messages.tvRequests,
|
||||
{
|
||||
quotaLimit: (
|
||||
<select
|
||||
className="inline short"
|
||||
value={limitOverride ?? quotaLimit}
|
||||
onChange={(e) => setQuotaLimit(Number(e.target.value))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<option value="0">
|
||||
{intl.formatMessage(messages.unlimited)}
|
||||
</option>
|
||||
{[...Array(100)].map((_item, i) => (
|
||||
<option value={i + 1} key={`${mediaType}-limit-${i + 1}`}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
quotaDays: (
|
||||
<select
|
||||
className="inline short"
|
||||
value={dayOverride ?? quotaDays}
|
||||
onChange={(e) => setQuotaDays(Number(e.target.value))}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{[...Array(100)].map((_item, i) => (
|
||||
<option value={i + 1} key={`${mediaType}-days-${i + 1}`}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
movies: intl.formatMessage(messages.movies, { count: quotaLimit }),
|
||||
seasons: intl.formatMessage(messages.seasons, { count: quotaLimit }),
|
||||
days: intl.formatMessage(messages.days, { count: quotaDays }),
|
||||
quotaUnits: function quotaUnits(msg) {
|
||||
return (
|
||||
<span className={limitOverride || quotaLimit ? '' : 'hidden'}>
|
||||
{msg}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(QuotaSelector);
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { countryCodeEmoji } from 'country-code-emoji';
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
|
||||
import { hasFlag } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
import { sortBy } from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { Region } from '../../../server/lib/settings';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
|
||||
const messages = defineMessages({
|
||||
regionDefault: 'All Regions',
|
||||
regionServerDefault: '{applicationTitle} Default ({region})',
|
||||
regionServerDefault: 'Default ({region})',
|
||||
});
|
||||
|
||||
interface RegionSelectorProps {
|
||||
@@ -37,6 +40,22 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
const sortedRegions = useMemo(() => {
|
||||
regions?.forEach((region) => {
|
||||
region.name =
|
||||
intl.formatDisplayName(region.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? region.english_name;
|
||||
});
|
||||
|
||||
return sortBy(regions, 'name');
|
||||
}, [intl, regions]);
|
||||
|
||||
const regionName = (regionCode: string) =>
|
||||
sortedRegions?.find((region) => region.iso_3166_1 === regionCode)?.name ??
|
||||
regionCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (regions && value) {
|
||||
if (value === 'all') {
|
||||
@@ -57,132 +76,82 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
}, [onChange, selectedRegion, name, regions]);
|
||||
|
||||
return (
|
||||
<div className="relative z-40 flex max-w-lg">
|
||||
<div className="w-full">
|
||||
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
|
||||
{({ 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">
|
||||
{selectedRegion && selectedRegion.iso_3166_1 !== 'all' && (
|
||||
<span className="h-4 mr-2 overflow-hidden text-lg leading-4">
|
||||
{countryCodeEmoji(selectedRegion.iso_3166_1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="block truncate">
|
||||
{selectedRegion && selectedRegion.iso_3166_1 !== 'all'
|
||||
? intl.formatDisplayName(selectedRegion.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? selectedRegion.english_name
|
||||
: isUserSetting && selectedRegion?.iso_3166_1 !== 'all'
|
||||
? intl.formatMessage(messages.regionServerDefault, {
|
||||
applicationTitle: currentSettings.applicationTitle,
|
||||
region: currentSettings.region
|
||||
? intl.formatDisplayName(currentSettings.region, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? currentSettings.region
|
||||
: intl.formatMessage(messages.regionDefault),
|
||||
})
|
||||
: intl.formatMessage(messages.regionDefault)}
|
||||
<div className="z-40 w-full">
|
||||
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
|
||||
{({ 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">
|
||||
{((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={`flag:${
|
||||
selectedRegion
|
||||
? selectedRegion.iso_3166_1
|
||||
: currentSettings.region
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
className="w-5 h-5 text-gray-500"
|
||||
>
|
||||
<path
|
||||
stroke="#6b7280"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6 8l4 4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
</span>
|
||||
)}
|
||||
<span className="block truncate">
|
||||
{selectedRegion && selectedRegion.iso_3166_1 !== 'all'
|
||||
? regionName(selectedRegion.iso_3166_1)
|
||||
: isUserSetting && selectedRegion?.iso_3166_1 !== 'all'
|
||||
? intl.formatMessage(messages.regionServerDefault, {
|
||||
region: currentSettings.region
|
||||
? regionName(currentSettings.region)
|
||||
: intl.formatMessage(messages.regionDefault),
|
||||
})
|
||||
: 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>
|
||||
</Listbox.Button>
|
||||
</span>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
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"
|
||||
<Transition
|
||||
show={open}
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{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`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionServerDefault, {
|
||||
applicationTitle:
|
||||
currentSettings.applicationTitle,
|
||||
region: currentSettings.region
|
||||
? intl.formatDisplayName(
|
||||
currentSettings.region,
|
||||
{
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}
|
||||
) ?? currentSettings.region
|
||||
: intl.formatMessage(messages.regionDefault),
|
||||
})}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
)}
|
||||
<Listbox.Option value={isUserSetting ? allRegion : null}>
|
||||
{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`}
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
|
||||
>
|
||||
<span className="mr-2 text-base">
|
||||
<span
|
||||
className={
|
||||
hasFlag(currentSettings.region)
|
||||
? `flag:${currentSettings.region}`
|
||||
: 'pr-6'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionDefault)}
|
||||
{intl.formatMessage(messages.regionServerDefault, {
|
||||
region: currentSettings.region
|
||||
? regionName(currentSettings.region)
|
||||
: intl.formatMessage(messages.regionDefault),
|
||||
})}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
@@ -190,76 +159,81 @@ const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
{regions?.map((region) => (
|
||||
<Listbox.Option key={region.iso_3166_1} value={region}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
)}
|
||||
<Listbox.Option value={isUserSetting ? allRegion : 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`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate pl-8`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
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 ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<span className="mr-2 text-lg">
|
||||
{countryCodeEmoji(region.iso_3166_1)}
|
||||
</span>
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
{sortedRegions?.map((region) => (
|
||||
<Listbox.Option key={region.iso_3166_1} value={region}>
|
||||
{({ 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`}
|
||||
>
|
||||
<span className="mr-2 text-base">
|
||||
<span
|
||||
className={
|
||||
hasFlag(region.iso_3166_1)
|
||||
? `flag:${region.iso_3166_1}`
|
||||
: 'pr-6'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{regionName(region.iso_3166_1)}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
{intl.formatDisplayName(region.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? region.english_name}
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import { FormattedDate, useIntl, defineMessages } from 'react-intl';
|
||||
import Badge from '../Common/Badge';
|
||||
import { MediaRequestStatus } from '../../../server/constants/media';
|
||||
import Button from '../Common/Button';
|
||||
import {
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
EyeIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
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 globalMessages from '../../i18n/globalMessages';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import RequestModal from '../RequestModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
requestoverrides: 'Request Overrides',
|
||||
server: 'Server',
|
||||
profilechanged: 'Profile Changed',
|
||||
server: 'Destination Server',
|
||||
profilechanged: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
});
|
||||
|
||||
@@ -65,43 +75,28 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="px-4 py-4">
|
||||
<div className="px-4 py-4 text-gray-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
|
||||
<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">
|
||||
<svg
|
||||
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<UserIcon className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{request.requestedBy.displayName}
|
||||
<Link href={`/users/${request.requestedBy.id}`}>
|
||||
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{request.requestedBy.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
{request.modifiedBy && (
|
||||
<div className="flex flex-nowrap">
|
||||
<svg
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<EyeIcon className="flex-shrink-0 mr-1.5 h-5 w-5" />
|
||||
<span className="w-40 truncate md:w-auto">
|
||||
{request.modifiedBy?.displayName}
|
||||
<Link href={`/users/${request.modifiedBy.id}`}>
|
||||
<a className="text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||
{request.modifiedBy.displayName}
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -109,62 +104,29 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
<div className="flex flex-wrap flex-shrink-0 ml-2">
|
||||
{request.status === MediaRequestStatus.PENDING && (
|
||||
<>
|
||||
<span className="mr-1">
|
||||
<Button
|
||||
buttonType="success"
|
||||
onClick={() => updateRequest('approve')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="mr-1">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
onClick={() => updateRequest('decline')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
buttonType="success"
|
||||
className="mr-1"
|
||||
onClick={() => updateRequest('approve')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<CheckIcon className="icon-sm" />
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
className="mr-1"
|
||||
onClick={() => updateRequest('decline')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<PencilIcon className="icon-sm" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{request.status !== MediaRequestStatus.PENDING && (
|
||||
@@ -173,25 +135,14 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
onClick={() => deleteRequest()}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<TrashIcon className="icon-sm" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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 text-gray-300">
|
||||
<div className="flex items-center mr-6 text-sm leading-5">
|
||||
{request.is4k && (
|
||||
<span className="mr-1">
|
||||
<Badge badgeType="warning">4K</Badge>
|
||||
@@ -214,27 +165,24 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
|
||||
<svg
|
||||
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<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" />
|
||||
<span>
|
||||
<FormattedDate value={request.createdAt} />
|
||||
{intl.formatDate(request.createdAt, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(request.seasons ?? []).length > 0 && (
|
||||
<div className="flex flex-col mt-2 text-sm">
|
||||
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
|
||||
<div className="mb-1 font-medium">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount: request.seasons.length,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{request.seasons.map((season) => (
|
||||
<span
|
||||
@@ -247,7 +195,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(server || profile || rootFolder) && (
|
||||
{(server || profile !== null || rootFolder) && (
|
||||
<>
|
||||
<div className="mt-4 mb-1 text-sm">
|
||||
{intl.formatMessage(messages.requestoverrides)}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import {
|
||||
CheckIcon,
|
||||
InformationCircleIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
@@ -9,28 +15,27 @@ import Media from '../../../server/entity/Media';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import RequestModal from '../RequestModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
viewrequest: 'View Request',
|
||||
viewrequest4k: 'View 4K Request',
|
||||
request: 'Request',
|
||||
request4k: 'Request 4K',
|
||||
requestmore: 'Request More',
|
||||
requestmore4k: 'Request More 4K',
|
||||
requestmore4k: 'Request More in 4K',
|
||||
approverequest: 'Approve Request',
|
||||
approverequest4k: 'Approve 4K Request',
|
||||
declinerequest: 'Decline Request',
|
||||
declinerequest4k: 'Decline 4K Request',
|
||||
approverequests:
|
||||
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
'Approve {requestCount, plural, one {Request} other {{requestCount} Requests}}',
|
||||
declinerequests:
|
||||
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
'Decline {requestCount, plural, one {Request} other {{requestCount} Requests}}',
|
||||
approve4krequests:
|
||||
'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
|
||||
'Approve {requestCount, plural, one {4K Request} other {{requestCount} 4K Requests}}',
|
||||
decline4krequests:
|
||||
'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
|
||||
'Decline {requestCount, plural, one {4K Request} other {{requestCount} 4K Requests}}',
|
||||
});
|
||||
|
||||
interface ButtonOption {
|
||||
@@ -59,26 +64,34 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { hasPermission } = useUser();
|
||||
const { user, hasPermission } = useUser();
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showRequest4kModal, setShowRequest4kModal] = useState(false);
|
||||
const [editRequest, setEditRequest] = useState(false);
|
||||
|
||||
const activeRequest = media?.requests.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||
);
|
||||
const active4kRequest = media?.requests.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
||||
);
|
||||
|
||||
// All pending
|
||||
// All pending requests
|
||||
const activeRequests = media?.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||
);
|
||||
|
||||
const active4kRequests = media?.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
||||
);
|
||||
|
||||
const activeRequest = useMemo(() => {
|
||||
return activeRequests && activeRequests.length > 0
|
||||
? activeRequests.find((request) => request.requestedBy.id === user?.id) ??
|
||||
activeRequests[0]
|
||||
: undefined;
|
||||
}, [activeRequests, user]);
|
||||
|
||||
const active4kRequest = useMemo(() => {
|
||||
return active4kRequests && active4kRequests.length > 0
|
||||
? active4kRequests.find(
|
||||
(request) => request.requestedBy.id === user?.id
|
||||
) ?? active4kRequests[0]
|
||||
: undefined;
|
||||
}, [active4kRequests, user]);
|
||||
|
||||
const modifyRequest = async (
|
||||
request: MediaRequest,
|
||||
type: 'approve' | 'decline'
|
||||
@@ -110,184 +123,83 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
const buttons: ButtonOption[] = [];
|
||||
if (
|
||||
(!media || media.status === MediaStatus.UNKNOWN) &&
|
||||
hasPermission(Permission.REQUEST)
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
mediaType === 'movie'
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
)
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request',
|
||||
text: intl.formatMessage(messages.request),
|
||||
text: intl.formatMessage(globalMessages.request),
|
||||
action: () => {
|
||||
setEditRequest(false);
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasPermission(Permission.REQUEST) &&
|
||||
mediaType === 'tv' &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.UNKNOWN &&
|
||||
!isShowComplete
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more',
|
||||
text: intl.formatMessage(messages.requestmore),
|
||||
action: () => {
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <DownloadIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(!media || media.status4k === MediaStatus.UNKNOWN) &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) ||
|
||||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
|
||||
hasPermission(
|
||||
[
|
||||
Permission.REQUEST_4K,
|
||||
mediaType === 'movie'
|
||||
? Permission.REQUEST_4K_MOVIE
|
||||
: Permission.REQUEST_4K_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
|
||||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request4k',
|
||||
text: intl.formatMessage(messages.request4k),
|
||||
text: intl.formatMessage(globalMessages.request4k),
|
||||
action: () => {
|
||||
setEditRequest(false);
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType === 'tv' &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
|
||||
media &&
|
||||
media.status4k !== MediaStatus.AVAILABLE &&
|
||||
media.status4k !== MediaStatus.UNKNOWN &&
|
||||
!is4kShowComplete &&
|
||||
settings.currentSettings.series4kEnabled
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more-4k',
|
||||
text: intl.formatMessage(messages.requestmore4k),
|
||||
action: () => {
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <DownloadIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequest &&
|
||||
mediaType === 'movie' &&
|
||||
hasPermission(Permission.REQUEST)
|
||||
(activeRequest.requestedBy.id === user?.id ||
|
||||
(activeRequests?.length === 1 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS)))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'active-request',
|
||||
text: intl.formatMessage(messages.viewrequest),
|
||||
action: () => setShowRequestModal(true),
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
action: () => {
|
||||
setEditRequest(true);
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: <InformationCircleIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequest &&
|
||||
mediaType === 'movie' &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
hasPermission(Permission.REQUEST_4K_MOVIE))
|
||||
(active4kRequest.requestedBy.id === user?.id ||
|
||||
(active4kRequests?.length === 1 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS)))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'active-4k-request',
|
||||
text: intl.formatMessage(messages.viewrequest4k),
|
||||
action: () => setShowRequest4kModal(true),
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
action: () => {
|
||||
setEditRequest(true);
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: <InformationCircleIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,20 +215,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
action: () => {
|
||||
modifyRequest(activeRequest, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <CheckIcon />,
|
||||
},
|
||||
{
|
||||
id: 'decline-request',
|
||||
@@ -324,20 +223,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
action: () => {
|
||||
modifyRequest(activeRequest, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <XIcon />,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -357,20 +243,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
action: () => {
|
||||
modifyRequests(activeRequests, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <CheckIcon />,
|
||||
},
|
||||
{
|
||||
id: 'decline-request-batch',
|
||||
@@ -380,20 +253,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
action: () => {
|
||||
modifyRequests(activeRequests, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <XIcon />,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -410,20 +270,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
action: () => {
|
||||
modifyRequest(active4kRequest, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <CheckIcon />,
|
||||
},
|
||||
{
|
||||
id: 'decline-4k-request',
|
||||
@@ -431,20 +278,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
action: () => {
|
||||
modifyRequest(active4kRequest, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <XIcon />,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -457,54 +291,73 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request-batch',
|
||||
id: 'approve-4k-request-batch',
|
||||
text: intl.formatMessage(messages.approve4krequests, {
|
||||
requestCount: active4kRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(active4kRequests, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <CheckIcon />,
|
||||
},
|
||||
{
|
||||
id: 'decline-request-batch',
|
||||
id: 'decline-4k-request-batch',
|
||||
text: intl.formatMessage(messages.decline4krequests, {
|
||||
requestCount: active4kRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(active4kRequests, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
svg: <XIcon />,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType === 'tv' &&
|
||||
(!activeRequest || activeRequest.requestedBy.id !== user?.id) &&
|
||||
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.UNKNOWN &&
|
||||
!isShowComplete
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more',
|
||||
text: intl.formatMessage(messages.requestmore),
|
||||
action: () => {
|
||||
setEditRequest(false);
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: <DownloadIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType === 'tv' &&
|
||||
(!active4kRequest || active4kRequest.requestedBy.id !== user?.id) &&
|
||||
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
|
||||
type: 'or',
|
||||
}) &&
|
||||
media &&
|
||||
media.status4k !== MediaStatus.AVAILABLE &&
|
||||
media.status4k !== MediaStatus.UNKNOWN &&
|
||||
!is4kShowComplete &&
|
||||
settings.currentSettings.series4kEnabled
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more-4k',
|
||||
text: intl.formatMessage(messages.requestmore4k),
|
||||
action: () => {
|
||||
setEditRequest(false);
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: <DownloadIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
const [buttonOne, ...others] = buttons;
|
||||
|
||||
if (!buttonOne) {
|
||||
@@ -517,6 +370,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
tmdbId={tmdbId}
|
||||
show={showRequestModal}
|
||||
type={mediaType}
|
||||
editRequest={editRequest ? activeRequest : undefined}
|
||||
onComplete={() => {
|
||||
onUpdate();
|
||||
setShowRequestModal(false);
|
||||
@@ -527,6 +381,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
tmdbId={tmdbId}
|
||||
show={showRequest4kModal}
|
||||
type={mediaType}
|
||||
editRequest={editRequest ? active4kRequest : undefined}
|
||||
is4k
|
||||
onComplete={() => {
|
||||
onUpdate();
|
||||
@@ -537,8 +392,8 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
<ButtonWithDropdown
|
||||
text={
|
||||
<>
|
||||
{buttonOne.svg ?? null}
|
||||
{buttonOne.text}
|
||||
{buttonOne.svg}
|
||||
<span>{buttonOne.text}</span>
|
||||
</>
|
||||
}
|
||||
onClick={buttonOne.action}
|
||||
@@ -551,7 +406,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
key={`request-option-${button.id}`}
|
||||
>
|
||||
{button.svg}
|
||||
{button.text}
|
||||
<span>{button.text}</span>
|
||||
</ButtonWithDropdown.Item>
|
||||
))
|
||||
: null}
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import type { TvDetails } from '../../../server/models/Tv';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import useSWR from 'swr';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { MediaRequestStatus } from '../../../server/constants/media';
|
||||
import Badge from '../Common/Badge';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import {
|
||||
CheckIcon,
|
||||
PencilIcon,
|
||||
RefreshIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Button from '../Common/Button';
|
||||
import { withProperties } from '../../utils/typeHelpers';
|
||||
import Link from 'next/link';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
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 { withProperties } from '../../utils/typeHelpers';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
import RequestModal from '../RequestModal';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
all: 'All',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
failedretry: 'Something went wrong while retrying the request.',
|
||||
mediaerror: 'The associated title for this request is no longer available.',
|
||||
deleterequest: 'Delete Request',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -27,7 +41,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
|
||||
const RequestCardPlaceholder: React.FC = () => {
|
||||
return (
|
||||
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
|
||||
<div className="relative p-4 bg-gray-700 rounded-xl w-72 sm:w-96 animate-pulse">
|
||||
<div className="w-20 sm:w-28">
|
||||
<div className="w-full" style={{ paddingBottom: '150%' }} />
|
||||
</div>
|
||||
@@ -35,6 +49,45 @@ const RequestCardPlaceholder: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
interface RequestCardErrorProps {
|
||||
mediaId?: number;
|
||||
}
|
||||
|
||||
const RequestCardError: React.FC<RequestCardErrorProps> = ({ mediaId }) => {
|
||||
const { hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/media/${mediaId}`);
|
||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative p-4 bg-gray-800 ring-1 ring-red-500 rounded-xl w-72 sm:w-96">
|
||||
<div className="w-20 sm:w-28">
|
||||
<div className="w-full" style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center w-full h-full px-10">
|
||||
<div className="w-full text-xs text-center text-gray-300 whitespace-normal sm:text-sm">
|
||||
{intl.formatMessage(messages.mediaerror)}
|
||||
</div>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
className="mt-4"
|
||||
onClick={() => deleteRequest()}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RequestCardProps {
|
||||
request: MediaRequest;
|
||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||
@@ -45,14 +98,16 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
triggerOnce: true,
|
||||
});
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { user, hasPermission } = useUser();
|
||||
const { addToast } = useToasts();
|
||||
const [isRetrying, setRetrying] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const url =
|
||||
request.type === 'movie'
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}?language=${locale}` : null
|
||||
inView ? `${url}` : null
|
||||
);
|
||||
const {
|
||||
data: requestData,
|
||||
@@ -70,6 +125,30 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/request/${request.id}`);
|
||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||
};
|
||||
|
||||
const retryRequest = async () => {
|
||||
setRetrying(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
|
||||
|
||||
if (response) {
|
||||
revalidate();
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.failedretry), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (title && onTitleData) {
|
||||
onTitleData(request.id, title);
|
||||
@@ -85,157 +164,242 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
}
|
||||
|
||||
if (!requestData && !requestError) {
|
||||
return <RequestCardPlaceholder />;
|
||||
return <RequestCardError />;
|
||||
}
|
||||
|
||||
if (!title || !requestData) {
|
||||
return <RequestCardPlaceholder />;
|
||||
return <RequestCardError mediaId={requestData?.media.id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 pr-4">
|
||||
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
|
||||
<Link
|
||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
||||
as={
|
||||
request.type === 'movie'
|
||||
? `/movie/${request.media.tmdbId}`
|
||||
: `/tv/${request.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</Link>
|
||||
</h2>
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center group">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
<>
|
||||
<RequestModal
|
||||
show={showEditModal}
|
||||
tmdbId={request.media.tmdbId}
|
||||
type={request.type}
|
||||
is4k={request.is4k}
|
||||
editRequest={request}
|
||||
onCancel={() => setShowEditModal(false)}
|
||||
onComplete={() => {
|
||||
revalidate();
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex p-4 overflow-hidden text-gray-400 bg-gray-800 bg-center bg-cover shadow rounded-xl w-72 sm:w-96 ring-1 ring-gray-700">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<CachedImage
|
||||
alt=""
|
||||
className="w-4 mr-1 rounded-full sm:mr-2 sm:w-5"
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<span className="text-xs truncate sm:text-sm group-hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
{requestData.media.status && (
|
||||
<div className="mt-1 sm:mt-2">
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.is4k
|
||||
? requestData.media.status4k
|
||||
: requestData.media.status
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{request.seasons.length > 0 && (
|
||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
||||
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
|
||||
{!isMovie(title) &&
|
||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length ? (
|
||||
<span className="mr-2 uppercase">
|
||||
<Badge>{intl.formatMessage(messages.all)}</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<div className="overflow-x-scroll hide-scrollbar">
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col flex-1 min-w-0 pr-4">
|
||||
<div className="hidden text-xs font-medium text-white sm:flex">
|
||||
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
|
||||
0,
|
||||
4
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="flex items-end flex-1">
|
||||
<span className="mr-2">
|
||||
<Link
|
||||
href={
|
||||
request.type === 'movie'
|
||||
? `/movie/${requestData.media.tmdbId}`
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="overflow-hidden text-base font-bold text-white sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
{hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
) && (
|
||||
<div className="card-field">
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center group">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm"
|
||||
/>
|
||||
<span className="truncate group-hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isMovie(title) && request.seasons.length > 0 && (
|
||||
<div className="items-center my-0.5 sm:my-1 text-sm hidden sm:flex">
|
||||
<span className="mr-2 font-bold ">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount:
|
||||
title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length
|
||||
? 0
|
||||
: request.seasons.length,
|
||||
})}
|
||||
</span>
|
||||
{title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length ? (
|
||||
<span className="mr-2 uppercase">
|
||||
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<div className="overflow-x-scroll hide-scrollbar">
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center mt-2 text-sm sm:mt-1">
|
||||
<span className="hidden mr-2 font-bold sm:block">
|
||||
{intl.formatMessage(globalMessages.status)}
|
||||
</span>
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||
}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
plexUrl={requestData.media.plexUrl}
|
||||
plexUrl4k={requestData.media.plexUrl4k}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end flex-1 space-x-2">
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN &&
|
||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="success"
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('approve')}
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.approve)}
|
||||
<RefreshIcon
|
||||
className={isRetrying ? 'animate-spin' : ''}
|
||||
style={{ marginRight: '0', animationDirection: 'reverse' }}
|
||||
/>
|
||||
<span className="hidden ml-1.5 sm:block">
|
||||
{intl.formatMessage(globalMessages.retry)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
<Button
|
||||
buttonType="success"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('approve')}
|
||||
>
|
||||
<CheckIcon style={{ marginRight: '0' }} />
|
||||
<span className="hidden ml-1.5 sm:block">
|
||||
{intl.formatMessage(globalMessages.approve)}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('decline')}
|
||||
>
|
||||
<XIcon style={{ marginRight: '0' }} />
|
||||
<span className="hidden ml-1.5 sm:block">
|
||||
{intl.formatMessage(globalMessages.decline)}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
!hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
requestData.requestedBy.id === user?.id &&
|
||||
(requestData.type === 'tv' ||
|
||||
hasPermission(Permission.REQUEST_ADVANCED)) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className={`${
|
||||
hasPermission(Permission.MANAGE_REQUESTS) ? 'sm:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<PencilIcon style={{ marginRight: '0' }} />
|
||||
<span className="hidden ml-1.5 sm:block">
|
||||
{intl.formatMessage(globalMessages.edit)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
!hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
requestData.requestedBy.id === user?.id && (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('decline')}
|
||||
onClick={() => deleteRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.decline)}
|
||||
<XIcon style={{ marginRight: '0' }} />
|
||||
<span className="hidden ml-1.5 sm:block">
|
||||
{intl.formatMessage(globalMessages.cancel)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-20 sm:w-28">
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
|
||||
as={
|
||||
href={
|
||||
request.type === 'movie'
|
||||
? `/movie/${request.media.tmdbId}`
|
||||
: `/tv/${request.media.tmdbId}`
|
||||
? `/movie/${requestData.media.tmdbId}`
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
title.posterPath
|
||||
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
|
||||
/>
|
||||
<a className="flex-shrink-0 w-20 overflow-hidden transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md">
|
||||
<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}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,41 +1,88 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
|
||||
import {
|
||||
useIntl,
|
||||
FormattedDate,
|
||||
FormattedRelativeTime,
|
||||
defineMessages,
|
||||
} from 'react-intl';
|
||||
import { useUser, Permission } from '../../../hooks/useUser';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import type { MovieDetails } from '../../../../server/models/Movie';
|
||||
import type { TvDetails } from '../../../../server/models/Tv';
|
||||
CheckIcon,
|
||||
PencilIcon,
|
||||
RefreshIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import Badge from '../../Common/Badge';
|
||||
import StatusBadge from '../../StatusBadge';
|
||||
import Table from '../../Common/Table';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../../server/constants/media';
|
||||
import Button from '../../Common/Button';
|
||||
import axios from 'axios';
|
||||
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
|
||||
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 Link from 'next/link';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Badge from '../../Common/Badge';
|
||||
import Button from '../../Common/Button';
|
||||
import CachedImage from '../../Common/CachedImage';
|
||||
import ConfirmButton from '../../Common/ConfirmButton';
|
||||
import RequestModal from '../../RequestModal';
|
||||
import StatusBadge from '../../StatusBadge';
|
||||
|
||||
const messages = defineMessages({
|
||||
seasons: 'Seasons',
|
||||
notavailable: 'N/A',
|
||||
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
|
||||
failedretry: 'Something went wrong while retrying the request.',
|
||||
requested: 'Requested',
|
||||
requesteddate: 'Requested',
|
||||
modified: 'Modified',
|
||||
modifieduserdate: '{date} by {user}',
|
||||
mediaerror: 'The associated title for this request is no longer available.',
|
||||
editrequest: 'Edit Request',
|
||||
deleterequest: 'Delete Request',
|
||||
cancelRequest: 'Cancel Request',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
return (movie as MovieDetails).title !== undefined;
|
||||
};
|
||||
|
||||
interface RequestItemErroProps {
|
||||
mediaId?: number;
|
||||
revalidateList: () => void;
|
||||
}
|
||||
|
||||
const RequestItemError: React.FC<RequestItemErroProps> = ({
|
||||
mediaId,
|
||||
revalidateList,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/media/${mediaId}`);
|
||||
revalidateList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-64 px-10 bg-gray-800 lg:flex-row ring-1 ring-red-500 rounded-xl xl:h-32">
|
||||
<span className="text-sm text-center text-gray-300 lg:text-left">
|
||||
{intl.formatMessage(messages.mediaerror)}
|
||||
</span>
|
||||
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
|
||||
<div className="mt-4 lg:ml-4 lg:mt-0">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => deleteRequest()}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RequestItemProps {
|
||||
request: MediaRequest;
|
||||
revalidateList: () => void;
|
||||
@@ -50,15 +97,14 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
});
|
||||
const { addToast } = useToasts();
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { user, hasPermission } = useUser();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const url =
|
||||
request.type === 'movie'
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}?language=${locale}` : null
|
||||
inView ? `${url}` : null
|
||||
);
|
||||
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
|
||||
`/api/v1/request/${request.id}`,
|
||||
@@ -101,22 +147,24 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
|
||||
if (!title && !error) {
|
||||
return (
|
||||
<tr className="w-full h-24 animate-pulse" ref={ref}>
|
||||
<td colSpan={6}></td>
|
||||
</tr>
|
||||
<div
|
||||
className="w-full h-64 bg-gray-800 rounded-xl xl:h-32 animate-pulse"
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!title || !requestData) {
|
||||
return (
|
||||
<tr className="w-full h-24 animate-pulse">
|
||||
<td colSpan={6}></td>
|
||||
</tr>
|
||||
<RequestItemError
|
||||
mediaId={requestData?.media.id}
|
||||
revalidateList={revalidateList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="relative w-full h-24 p-2">
|
||||
<>
|
||||
<RequestModal
|
||||
show={showEditModal}
|
||||
tmdbId={request.media.tmdbId}
|
||||
@@ -129,28 +177,26 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<Table.TD>
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={
|
||||
request.type === 'movie'
|
||||
? `/movie/${request.media.tmdbId}`
|
||||
: `/tv/${request.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="flex-shrink-0 hidden mr-4 sm:block">
|
||||
<img
|
||||
src={
|
||||
title.posterPath
|
||||
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
className="w-12 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer transform-gpu hover:scale-105 hover:shadow-md"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex-shrink overflow-hidden">
|
||||
<div className="relative flex flex-col justify-between w-full py-4 overflow-hidden text-gray-400 bg-gray-800 shadow-md ring-1 ring-gray-700 rounded-xl xl:h-32 xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-center bg-cover xl:w-2/3">
|
||||
<CachedImage
|
||||
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
|
||||
alt=""
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col justify-between w-full overflow-hidden sm:flex-row">
|
||||
<div className="relative z-10 flex items-center w-full pl-4 pr-4 overflow-hidden xl:w-7/12 2xl:w-2/3 sm:pr-0">
|
||||
<Link
|
||||
href={
|
||||
requestData.type === 'movie'
|
||||
@@ -158,219 +204,285 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="min-w-0 mr-2 text-xl text-white truncate hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center mt-1">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
<a className="relative flex-shrink-0 w-12 h-auto overflow-hidden transition duration-300 scale-100 rounded-md sm:w-14 transform-gpu hover:scale-105">
|
||||
<CachedImage
|
||||
src={
|
||||
title.posterPath
|
||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||
: '/images/overseerr_poster_not_found.png'
|
||||
}
|
||||
alt=""
|
||||
className="w-5 mr-2 rounded-full"
|
||||
layout="responsive"
|
||||
width={600}
|
||||
height={900}
|
||||
objectFit="cover"
|
||||
/>
|
||||
<span className="text-sm hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
{requestData.seasons.length > 0 && (
|
||||
<div className="items-center hidden mt-2 text-sm sm:flex">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.seasons)}
|
||||
</span>
|
||||
{requestData.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
<div className="flex flex-col justify-center pl-2 overflow-hidden xl:pl-4">
|
||||
<div className="font-medium pt-0.5 sm:pt-1 text-xs text-white">
|
||||
{(isMovie(title)
|
||||
? title.releaseDate
|
||||
: title.firstAirDate
|
||||
)?.slice(0, 4)}
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
requestData.type === 'movie'
|
||||
? `/movie/${requestData.media.tmdbId}`
|
||||
: `/tv/${requestData.media.tmdbId}`
|
||||
}
|
||||
>
|
||||
<a className="min-w-0 mr-2 text-lg font-bold text-white truncate xl:text-xl hover:underline">
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</a>
|
||||
</Link>
|
||||
{!isMovie(title) && request.seasons.length > 0 && (
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount:
|
||||
title.seasons.filter(
|
||||
(season) => season.seasonNumber !== 0
|
||||
).length === request.seasons.length
|
||||
? 0
|
||||
: request.seasons.length,
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
{title.seasons.filter((season) => season.seasonNumber !== 0)
|
||||
.length === request.seasons.length ? (
|
||||
<span className="mr-2 uppercase">
|
||||
<Badge>{intl.formatMessage(globalMessages.all)}</Badge>
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex overflow-x-scroll hide-scrollbar flex-nowrap">
|
||||
{request.seasons.map((season) => (
|
||||
<span key={`season-${season.id}`} className="mr-2">
|
||||
<Badge>{season.seasonNumber}</Badge>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 flex flex-col justify-center w-full pr-4 mt-4 ml-4 overflow-hidden text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(globalMessages.status)}
|
||||
</span>
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={
|
||||
requestData.media[requestData.is4k ? 'status4k' : 'status']
|
||||
}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
plexUrl={requestData.media.plexUrl}
|
||||
plexUrl4k={requestData.media.plexUrl4k}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-field">
|
||||
{hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
) ? (
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.requested)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
{intl.formatMessage(messages.modifieduserdate, {
|
||||
date: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(requestData.createdAt).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
user: (
|
||||
<Link href={`/users/${requestData.requestedBy.id}`}>
|
||||
<a className="flex items-center truncate group">
|
||||
<img
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm truncate group-hover:underline">
|
||||
{requestData.requestedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.requesteddate)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(requestData.createdAt).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{requestData.modifiedBy && (
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.modified)}
|
||||
</span>
|
||||
<span className="flex text-sm text-gray-300 truncate">
|
||||
{intl.formatMessage(messages.modifieduserdate, {
|
||||
date: (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(requestData.updatedAt).getTime() -
|
||||
Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
),
|
||||
user: (
|
||||
<Link href={`/users/${requestData.modifiedBy.id}`}>
|
||||
<a className="flex items-center truncate group">
|
||||
<img
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="ml-1.5 avatar-sm"
|
||||
/>
|
||||
<span className="text-sm truncate group-hover:underline">
|
||||
{requestData.modifiedBy.displayName}
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN ||
|
||||
requestData.status === MediaRequestStatus.DECLINED ? (
|
||||
<Badge badgeType="danger">
|
||||
{requestData.status === MediaRequestStatus.DECLINED
|
||||
? intl.formatMessage(globalMessages.declined)
|
||||
: intl.formatMessage(globalMessages.failed)}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={requestData.media[requestData.is4k ? 'status4k' : 'status']}
|
||||
inProgress={
|
||||
(
|
||||
requestData.media[
|
||||
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
|
||||
] ?? []
|
||||
).length > 0
|
||||
}
|
||||
is4k={requestData.is4k}
|
||||
/>
|
||||
)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-300">
|
||||
<FormattedDate value={requestData.createdAt} />
|
||||
</span>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
<div className="flex flex-col">
|
||||
{requestData.modifiedBy ? (
|
||||
<span className="text-sm text-gray-300">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="w-5 mr-2 rounded-full"
|
||||
<div className="z-10 flex flex-col justify-center w-full pl-4 pr-4 mt-4 space-y-2 xl:mt-0 xl:items-end xl:w-96 xl:pl-0">
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN &&
|
||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="primary"
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryRequest()}
|
||||
>
|
||||
<RefreshIcon
|
||||
className={isRetrying ? 'animate-spin' : ''}
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{requestData.modifiedBy.displayName} (
|
||||
<FormattedRelativeTime
|
||||
value={Math.floor(
|
||||
(new Date(requestData.updatedAt).getTime() - Date.now()) /
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
/>
|
||||
)
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
isRetrying ? globalMessages.retrying : globalMessages.retry
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.deleterequest)}</span>
|
||||
</ConfirmButton>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<div className="flex flex-row w-full space-x-2">
|
||||
<span className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="success"
|
||||
onClick={() => modifyRequest('approve')}
|
||||
>
|
||||
<CheckIcon />
|
||||
<span>{intl.formatMessage(globalMessages.approve)}</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="danger"
|
||||
onClick={() => modifyRequest('decline')}
|
||||
>
|
||||
<XIcon />
|
||||
<span>{intl.formatMessage(globalMessages.decline)}</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-300">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.UNKNOWN &&
|
||||
requestData.status !== MediaRequestStatus.DECLINED &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
className="mr-2"
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
disabled={isRetrying}
|
||||
onClick={() => retryRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="18px"
|
||||
height="18px"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.retry)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status !== MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => deleteRequest()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.delete)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) && (
|
||||
<>
|
||||
<span className="mr-2">
|
||||
<Button
|
||||
buttonType="success"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('approve')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.approve)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
onClick={() => modifyRequest('decline')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.decline)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
(requestData.requestedBy.id === user?.id &&
|
||||
(requestData.type === 'tv' ||
|
||||
hasPermission(Permission.REQUEST_ADVANCED)))) && (
|
||||
<span className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-0 sm:mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
<span className="hidden sm:block">
|
||||
{intl.formatMessage(globalMessages.edit)}
|
||||
</span>
|
||||
<PencilIcon />
|
||||
<span>{intl.formatMessage(messages.editrequest)}</span>
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
)}
|
||||
{requestData.status === MediaRequestStatus.PENDING &&
|
||||
!hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
requestData.requestedBy.id === user?.id && (
|
||||
<ConfirmButton
|
||||
onClick={() => deleteRequest()}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
className="w-full"
|
||||
>
|
||||
<XIcon />
|
||||
<span>{intl.formatMessage(messages.cancelRequest)}</span>
|
||||
</ConfirmButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,51 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
SortDescendingIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import RequestItem from './RequestItem';
|
||||
import Header from '../Common/Header';
|
||||
import Table from '../Common/Table';
|
||||
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Button from '../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import RequestItem from './RequestItem';
|
||||
|
||||
const messages = defineMessages({
|
||||
requests: 'Requests',
|
||||
mediaInfo: 'Media Info',
|
||||
status: 'Status',
|
||||
requestedAt: 'Requested At',
|
||||
modifiedBy: 'Last Modified By',
|
||||
showingresults:
|
||||
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
|
||||
resultsperpage: 'Display {pageSize} results per page',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
filterAll: 'All',
|
||||
filterPending: 'Pending',
|
||||
filterApproved: 'Approved',
|
||||
filterAvailable: 'Available',
|
||||
filterProcessing: 'Processing',
|
||||
noresults: 'No results.',
|
||||
showallrequests: 'Show All Requests',
|
||||
sortAdded: 'Request Date',
|
||||
sortModified: 'Last Modified',
|
||||
});
|
||||
|
||||
type Filter = 'all' | 'pending' | 'approved' | 'processing' | 'available';
|
||||
enum Filter {
|
||||
ALL = 'all',
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
PROCESSING = 'processing',
|
||||
AVAILABLE = 'available',
|
||||
UNAVAILABLE = 'unavailable',
|
||||
}
|
||||
|
||||
type Sort = 'added' | 'modified';
|
||||
|
||||
const RequestList: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>('pending');
|
||||
const { user } = useUser({
|
||||
id: Number(router.query.userId),
|
||||
});
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
|
||||
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, revalidate } = useSWR<RequestResultsResponse>(
|
||||
`/api/v1/request?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}&sort=${currentSort}`
|
||||
}&filter=${currentFilter}&sort=${currentSort}${
|
||||
router.query.userId ? `&requestedBy=${router.query.userId}` : ''
|
||||
}`
|
||||
);
|
||||
|
||||
// Restore last set filter values on component mount
|
||||
useEffect(() => {
|
||||
const filterString = window.localStorage.getItem('rl-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(
|
||||
'rl-filter-settings',
|
||||
JSON.stringify({
|
||||
currentFilter,
|
||||
currentSort,
|
||||
currentPageSize,
|
||||
})
|
||||
);
|
||||
}, [currentFilter, currentSort, currentPageSize]);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -59,73 +102,81 @@ const RequestList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.requests)} />
|
||||
<div className="flex flex-col justify-between lg:items-end lg:flex-row">
|
||||
<Header>{intl.formatMessage(messages.requests)}</Header>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.requests),
|
||||
router.query.userId ? user?.displayName : '',
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-col justify-between mb-4 lg:items-end lg:flex-row">
|
||||
<Header
|
||||
subtext={
|
||||
router.query.userId ? (
|
||||
<Link href={`/users/${user?.id}`}>
|
||||
<a className="hover:underline">{user?.displayName}</a>
|
||||
</Link>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages.requests)}
|
||||
</Header>
|
||||
<div className="flex flex-col flex-grow mt-2 sm:flex-row lg:flex-grow-0">
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-sm text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default rounded-l-md">
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FilterIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setPageIndex(0);
|
||||
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(messages.filterAll)}
|
||||
{intl.formatMessage(globalMessages.all)}
|
||||
</option>
|
||||
<option value="pending">
|
||||
{intl.formatMessage(messages.filterPending)}
|
||||
{intl.formatMessage(globalMessages.pending)}
|
||||
</option>
|
||||
<option value="approved">
|
||||
{intl.formatMessage(messages.filterApproved)}
|
||||
{intl.formatMessage(globalMessages.approved)}
|
||||
</option>
|
||||
<option value="processing">
|
||||
{intl.formatMessage(messages.filterProcessing)}
|
||||
{intl.formatMessage(globalMessages.processing)}
|
||||
</option>
|
||||
<option value="available">
|
||||
{intl.formatMessage(messages.filterAvailable)}
|
||||
{intl.formatMessage(globalMessages.available)}
|
||||
</option>
|
||||
<option value="unavailable">
|
||||
{intl.formatMessage(globalMessages.unavailable)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-grow mb-2 sm:mb-0 lg:flex-grow-0">
|
||||
<span className="inline-flex items-center px-3 text-gray-100 bg-gray-800 border border-r-0 border-gray-500 cursor-default sm:text-sm rounded-l-md">
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M3 3a1 1 0 000 2h11a1 1 0 100-2H3zM3 7a1 1 0 000 2h7a1 1 0 100-2H3zM3 11a1 1 0 100 2h4a1 1 0 100-2H3zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
|
||||
</svg>
|
||||
<SortDescendingIcon className="w-6 h-6" />
|
||||
</span>
|
||||
<select
|
||||
id="sort"
|
||||
name="sort"
|
||||
onChange={(e) => {
|
||||
setPageIndex(0);
|
||||
setCurrentSort(e.target.value as Sort);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setPageIndex(0);
|
||||
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"
|
||||
@@ -140,114 +191,104 @@ const RequestList: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Table.TH>{intl.formatMessage(messages.mediaInfo)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.status)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.requestedAt)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.modifiedBy)}</Table.TH>
|
||||
<Table.TH></Table.TH>
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
{data.results.map((request) => {
|
||||
return (
|
||||
<RequestItem
|
||||
request={request}
|
||||
key={`request-list-${request.id}`}
|
||||
revalidateList={() => revalidate()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{data.results.map((request) => {
|
||||
return (
|
||||
<div className="py-2" key={`request-list-${request.id}`}>
|
||||
<RequestItem
|
||||
request={request}
|
||||
revalidateList={() => revalidate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{data.results.length === 0 && (
|
||||
<tr className="relative h-24 p-2 text-white">
|
||||
<Table.TD colSpan={6} noPadding>
|
||||
<div className="flex flex-col items-center justify-center w-screen p-6 lg:w-full">
|
||||
<span className="text-base">
|
||||
{intl.formatMessage(messages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== 'all' && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter('all')}
|
||||
>
|
||||
{intl.formatMessage(messages.showallrequests)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="bg-gray-700">
|
||||
<Table.TD colSpan={6} 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 lg:w-full"
|
||||
aria-label="Pagination"
|
||||
{data.results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full py-24 text-white">
|
||||
<span className="text-2xl text-gray-400">
|
||||
{intl.formatMessage(globalMessages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== Filter.ALL && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{data.results.length > 0 &&
|
||||
intl.formatMessage(messages.showingresults, {
|
||||
from: pageIndex * currentPageSize + 1,
|
||||
to:
|
||||
data.results.length < currentPageSize
|
||||
? pageIndex * currentPageSize + data.results.length
|
||||
: (pageIndex + 1) * currentPageSize,
|
||||
total: data.pageInfo.results,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-medium">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||
<span className="items-center -mt-3 text-sm sm:-ml-4 lg:ml-0 sm:mt-0">
|
||||
{intl.formatMessage(messages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setPageIndex(0);
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() => setPageIndex((current) => current - 1)}
|
||||
{intl.formatMessage(messages.showallrequests)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<nav
|
||||
className="flex flex-col items-center mb-3 space-y-3 sm:space-y-0 sm:flex-row"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div className="hidden lg:flex lg:flex-1">
|
||||
<p className="text-sm">
|
||||
{data.results.length > 0 &&
|
||||
intl.formatMessage(globalMessages.showingresults, {
|
||||
from: pageIndex * currentPageSize + 1,
|
||||
to:
|
||||
data.results.length < currentPageSize
|
||||
? pageIndex * currentPageSize + data.results.length
|
||||
: (pageIndex + 1) * currentPageSize,
|
||||
total: data.pageInfo.results,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-medium">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
|
||||
<span className="items-center -mt-3 text-sm truncate sm:mt-0">
|
||||
{intl.formatMessage(globalMessages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
router
|
||||
.push({
|
||||
pathname: router.pathname,
|
||||
query: router.query.userId
|
||||
? { userId: router.query.userId }
|
||||
: {},
|
||||
})
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
{intl.formatMessage(messages.previous)}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => setPageIndex((current) => current + 1)}
|
||||
>
|
||||
{intl.formatMessage(messages.next)}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() => updateQueryParams('page', (page - 1).toString())}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span>{intl.formatMessage(globalMessages.previous)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => updateQueryParams('page', (page + 1).toString())}
|
||||
>
|
||||
<span>{intl.formatMessage(globalMessages.next)}</span>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
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 useSWR from 'swr';
|
||||
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
|
||||
import type {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from '../../../../server/interfaces/api/serviceInterfaces';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { formatBytes } from '../../../utils/numberHelpers';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { Permission, User, useUser } from '../../../hooks/useUser';
|
||||
import type { UserResultsResponse } from '../../../../server/interfaces/api/userInterfaces';
|
||||
import { Permission, User, useUser } from '../../../hooks/useUser';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import { formatBytes } from '../../../utils/numberHelpers';
|
||||
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
|
||||
|
||||
type OptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const Select = dynamic(() => import('react-select'), { ssr: false });
|
||||
|
||||
const messages = defineMessages({
|
||||
advancedoptions: 'Advanced Options',
|
||||
advancedoptions: 'Advanced',
|
||||
destinationserver: 'Destination Server',
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
animenote: '* This series is an anime.',
|
||||
default: '(Default)',
|
||||
loadingprofiles: 'Loading profiles…',
|
||||
loadingfolders: 'Loading folders…',
|
||||
default: '{name} (Default)',
|
||||
folder: '{path} ({space})',
|
||||
requestas: 'Request As',
|
||||
languageprofile: 'Language Profile',
|
||||
loadinglanguages: 'Loading languages…',
|
||||
tags: 'Tags',
|
||||
selecttags: 'Select tags',
|
||||
notagoptions: 'No tags.',
|
||||
});
|
||||
|
||||
export type RequestOverrides = {
|
||||
server?: number;
|
||||
profile?: number;
|
||||
folder?: string;
|
||||
tags?: number[];
|
||||
language?: number;
|
||||
user?: User;
|
||||
};
|
||||
@@ -78,6 +93,10 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
defaultOverrides?.language ?? -1
|
||||
);
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<number[]>(
|
||||
defaultOverrides?.tags ?? []
|
||||
);
|
||||
|
||||
const {
|
||||
data: serverData,
|
||||
isValidating,
|
||||
@@ -100,7 +119,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
|
||||
const { data: userData } = useSWR<UserResultsResponse>(
|
||||
hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS])
|
||||
? '/api/v1/user?take=1000'
|
||||
? '/api/v1/user?take=1000&sort=displayname'
|
||||
: null
|
||||
);
|
||||
|
||||
@@ -151,6 +170,9 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
? serverData.server.activeAnimeLanguageProfileId
|
||||
: serverData.server.activeLanguageProfileId)
|
||||
);
|
||||
const defaultTags = isAnime
|
||||
? serverData.server.activeAnimeTags
|
||||
: serverData.server.activeTags;
|
||||
|
||||
if (
|
||||
defaultProfile &&
|
||||
@@ -175,46 +197,43 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
) {
|
||||
setSelectedLanguage(defaultLanguage.id);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultTags &&
|
||||
!isEqual(defaultTags, selectedTags) &&
|
||||
(!defaultOverrides || defaultOverrides.tags === null)
|
||||
) {
|
||||
setSelectedTags(defaultTags);
|
||||
}
|
||||
}
|
||||
}, [serverData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.server !== null &&
|
||||
defaultOverrides.server !== undefined
|
||||
) {
|
||||
if (defaultOverrides && defaultOverrides.server != null) {
|
||||
setSelectedServer(defaultOverrides.server);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.profile !== null &&
|
||||
defaultOverrides.profile !== undefined
|
||||
) {
|
||||
if (defaultOverrides && defaultOverrides.profile != null) {
|
||||
setSelectedProfile(defaultOverrides.profile);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.folder !== null &&
|
||||
defaultOverrides.folder !== undefined
|
||||
) {
|
||||
if (defaultOverrides && defaultOverrides.folder != null) {
|
||||
setSelectedFolder(defaultOverrides.folder);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.language !== null &&
|
||||
defaultOverrides.language !== undefined
|
||||
) {
|
||||
if (defaultOverrides && defaultOverrides.language != null) {
|
||||
setSelectedLanguage(defaultOverrides.language);
|
||||
}
|
||||
|
||||
if (defaultOverrides && defaultOverrides.tags != null) {
|
||||
setSelectedTags(defaultOverrides.tags);
|
||||
}
|
||||
}, [
|
||||
defaultOverrides?.server,
|
||||
defaultOverrides?.folder,
|
||||
defaultOverrides?.profile,
|
||||
defaultOverrides?.language,
|
||||
defaultOverrides?.tags,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,6 +244,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
server: selectedServer ?? undefined,
|
||||
user: selectedUser ?? undefined,
|
||||
language: selectedLanguage ?? undefined,
|
||||
tags: selectedTags,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
@@ -233,6 +253,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
selectedProfile,
|
||||
selectedUser,
|
||||
selectedLanguage,
|
||||
selectedTags,
|
||||
]);
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -250,23 +271,15 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center mb-2 font-bold tracking-wider">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9.707 7.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L13 8.586V5h3a2 2 0 012 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2V7a2 2 0 012-2h3v3.586L9.707 7.293zM11 3a1 1 0 112 0v2h-2V3z" />
|
||||
<path d="M4 9a2 2 0 00-2 2v5a2 2 0 002 2h8a2 2 0 002-2H4V9z" />
|
||||
</svg>
|
||||
<AdjustmentsIcon className="w-5 h-5 mr-1.5" />
|
||||
{intl.formatMessage(messages.advancedoptions)}
|
||||
</div>
|
||||
<div className="p-4 bg-gray-600 rounded-md shadow">
|
||||
{!!data && selectedServer !== null && (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
|
||||
<label htmlFor="server" className="text-label">
|
||||
<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">
|
||||
<label htmlFor="server">
|
||||
{intl.formatMessage(messages.destinationserver)}
|
||||
</label>
|
||||
<select
|
||||
@@ -275,20 +288,30 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
value={selectedServer}
|
||||
onChange={(e) => setSelectedServer(Number(e.target.value))}
|
||||
onBlur={(e) => setSelectedServer(Number(e.target.value))}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
className="bg-gray-800 border-gray-700"
|
||||
>
|
||||
{data.map((server) => (
|
||||
<option key={`server-list-${server.id}`} value={server.id}>
|
||||
{server.name}
|
||||
{server.isDefault && server.is4k === is4k
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
{data
|
||||
.filter((server) => server.is4k === is4k)
|
||||
.map((server) => (
|
||||
<option
|
||||
key={`server-list-${server.id}`}
|
||||
value={server.id}
|
||||
>
|
||||
{server.isDefault
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: server.name,
|
||||
})
|
||||
: server.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
|
||||
<label htmlFor="profile" className="text-label">
|
||||
)}
|
||||
{(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">
|
||||
<label htmlFor="profile">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</label>
|
||||
<select
|
||||
@@ -297,11 +320,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
value={selectedProfile}
|
||||
onChange={(e) => setSelectedProfile(Number(e.target.value))}
|
||||
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
className="bg-gray-800 border-gray-700"
|
||||
disabled={isValidating || !serverData}
|
||||
>
|
||||
{isValidating && (
|
||||
{(isValidating || !serverData) && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadingprofiles)}
|
||||
{intl.formatMessage(globalMessages.loading)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
@@ -311,24 +335,27 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
key={`profile-list${profile.id}`}
|
||||
value={profile.id}
|
||||
>
|
||||
{profile.name}
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeProfileId === profile.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: profile.name,
|
||||
})
|
||||
: !isAnime &&
|
||||
serverData.server.activeProfileId === profile.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: profile.name,
|
||||
})
|
||||
: profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0 ${
|
||||
type === 'tv' ? 'md:pr-4' : ''
|
||||
}`}
|
||||
>
|
||||
<label htmlFor="folder" className="text-label">
|
||||
)}
|
||||
{(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">
|
||||
<label htmlFor="folder">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</label>
|
||||
<select
|
||||
@@ -337,11 +364,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
value={selectedFolder}
|
||||
onChange={(e) => setSelectedFolder(e.target.value)}
|
||||
onBlur={(e) => setSelectedFolder(e.target.value)}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
className="bg-gray-800 border-gray-700"
|
||||
disabled={isValidating || !serverData}
|
||||
>
|
||||
{isValidating && (
|
||||
{(isValidating || !serverData) && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadingfolders)}
|
||||
{intl.formatMessage(globalMessages.loading)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
@@ -351,21 +379,37 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
key={`folder-list${folder.id}`}
|
||||
value={folder.path}
|
||||
>
|
||||
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeDirectory === folder.path
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: intl.formatMessage(messages.folder, {
|
||||
path: folder.path,
|
||||
space: formatBytes(folder.freeSpace ?? 0),
|
||||
}),
|
||||
})
|
||||
: !isAnime &&
|
||||
serverData.server.activeDirectory === folder.path
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: intl.formatMessage(messages.folder, {
|
||||
path: folder.path,
|
||||
space: formatBytes(folder.freeSpace ?? 0),
|
||||
}),
|
||||
})
|
||||
: intl.formatMessage(messages.folder, {
|
||||
path: folder.path,
|
||||
space: formatBytes(folder.freeSpace ?? 0),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{type === 'tv' && (
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0">
|
||||
<label htmlFor="language" className="text-label">
|
||||
)}
|
||||
{type === 'tv' &&
|
||||
(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">
|
||||
<label htmlFor="language">
|
||||
{intl.formatMessage(messages.languageprofile)}
|
||||
</label>
|
||||
<select
|
||||
@@ -378,11 +422,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
onBlur={(e) =>
|
||||
setSelectedLanguage(parseInt(e.target.value))
|
||||
}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
className="bg-gray-800 border-gray-700"
|
||||
disabled={isValidating || !serverData}
|
||||
>
|
||||
{isValidating && (
|
||||
{(isValidating || !serverData) && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadinglanguages)}
|
||||
{intl.formatMessage(globalMessages.loading)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
@@ -392,146 +437,169 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
key={`folder-list${language.id}`}
|
||||
value={language.id}
|
||||
>
|
||||
{language.name}
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeLanguageProfileId ===
|
||||
language.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: language.name,
|
||||
})
|
||||
: !isAnime &&
|
||||
serverData.server.activeLanguageProfileId ===
|
||||
language.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
? intl.formatMessage(messages.default, {
|
||||
name: language.name,
|
||||
})
|
||||
: language.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{selectedServer !== null &&
|
||||
(isValidating || !serverData || !!serverData?.tags?.length) && (
|
||||
<div className="mb-2">
|
||||
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
|
||||
<Select
|
||||
name="tags"
|
||||
options={(serverData?.tags ?? []).map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))}
|
||||
isMulti
|
||||
isDisabled={isValidating || !serverData}
|
||||
placeholder={
|
||||
isValidating || !serverData
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.selecttags)
|
||||
}
|
||||
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));
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.notagoptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
|
||||
selectedUser && (
|
||||
<div className="mt-0 sm:mt-2">
|
||||
<Listbox
|
||||
as="div"
|
||||
value={selectedUser}
|
||||
onChange={(value) => setSelectedUser(value)}
|
||||
className="space-y-1"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Label className="text-label">
|
||||
{intl.formatMessage(messages.requestas)}
|
||||
</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">
|
||||
<span className="flex items-center">
|
||||
<img
|
||||
src={selectedUser.avatar}
|
||||
alt=""
|
||||
className="flex-shrink-0 w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span className="block ml-3">
|
||||
{selectedUser.displayName}
|
||||
</span>
|
||||
<Listbox
|
||||
as="div"
|
||||
value={selectedUser}
|
||||
onChange={(value) => setSelectedUser(value)}
|
||||
className="space-y-1"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Listbox.Label>
|
||||
{intl.formatMessage(messages.requestas)}
|
||||
</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">
|
||||
<span className="flex items-center">
|
||||
<img
|
||||
src={selectedUser.avatar}
|
||||
alt=""
|
||||
className="flex-shrink-0 w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span className="block ml-3">
|
||||
{selectedUser.displayName}
|
||||
</span>
|
||||
{selectedUser.displayName.toLowerCase() !==
|
||||
selectedUser.email && (
|
||||
<span className="ml-1 text-gray-400 truncate">
|
||||
({selectedUser.email})
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
</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>
|
||||
</Listbox.Button>
|
||||
</span>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition ease-in duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="w-full mt-1 bg-gray-800 rounded-md shadow-lg"
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition ease-in duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="w-full mt-1 bg-gray-800 rounded-md 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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{userData?.results.map((user) => (
|
||||
<Listbox.Option key={user.id} value={user}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
{userData?.results.map((user) => (
|
||||
<Listbox.Option key={user.id} value={user}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
active
|
||||
? 'text-white bg-indigo-600'
|
||||
: 'text-gray-300'
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
active
|
||||
? 'text-white bg-indigo-600'
|
||||
: 'text-gray-300'
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4`}
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} flex items-center`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} flex items-center`}
|
||||
>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
className="flex-shrink-0 w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span className="flex-shrink-0 block ml-3">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
className="flex-shrink-0 w-6 h-6 rounded-full"
|
||||
/>
|
||||
<span className="flex-shrink-0 block ml-3">
|
||||
{user.displayName}
|
||||
</span>
|
||||
{user.displayName.toLowerCase() !==
|
||||
user.email && (
|
||||
<span className="ml-1 text-gray-400 truncate">
|
||||
({user.email})
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active
|
||||
? 'text-white'
|
||||
: 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{isAnime && (
|
||||
<div className="mt-4 italic">
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import Modal from '../Common/Modal';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaRequestStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import DownloadIcon from '../../assets/download.svg';
|
||||
import Alert from '../Common/Alert';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import React, { useCallback, useEffect, 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 { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Modal from '../Common/Modal';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import QuotaDisplay from './QuotaDisplay';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin:
|
||||
'Your request will be immediately approved. Do you wish to continue?',
|
||||
cancelrequest:
|
||||
'This will remove your request. Are you sure you want to continue?',
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
requestCancel: 'Request for <strong>{title}</strong> canceled',
|
||||
requestadmin: 'This request will be approved automatically.',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
requestCancel: 'Request for <strong>{title}</strong> canceled.',
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
close: 'Close',
|
||||
edit: 'Edit Request',
|
||||
cancel: 'Cancel Request',
|
||||
cancelling: 'Canceling…',
|
||||
pendingrequest: 'Pending request for {title}',
|
||||
pending4krequest: 'Pending request for {title} in 4K',
|
||||
requesting: 'Requesting…',
|
||||
request: 'Request',
|
||||
request4k: 'Request 4K',
|
||||
requestfrom: 'There is a pending request from {username}.',
|
||||
request4kfrom: 'There is a pending 4K request from {username}.',
|
||||
pendingrequest: 'Pending Request for {title}',
|
||||
pending4krequest: 'Pending 4K Request for {title}',
|
||||
requestfrom: "{username}'s request is pending approval.",
|
||||
errorediting: 'Something went wrong while editing the request.',
|
||||
requestedited: 'Request edited.',
|
||||
autoapproval: 'Automatic Approval',
|
||||
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
|
||||
requesterror: 'Something went wrong while submitting the request.',
|
||||
pendingapproval: 'Your request is pending approval.',
|
||||
});
|
||||
|
||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -70,6 +61,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
});
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (onUpdating) {
|
||||
@@ -88,6 +82,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
profileId: requestOverrides.profile,
|
||||
rootFolder: requestOverrides.folder,
|
||||
userId: requestOverrides.user?.id,
|
||||
tags: requestOverrides.tags,
|
||||
};
|
||||
}
|
||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
||||
@@ -132,18 +127,14 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [data, onComplete, addToast, requestOverrides]);
|
||||
|
||||
const activeRequest = data?.mediaInfo?.requests?.find(
|
||||
(request) => request.is4k === !!is4k
|
||||
);
|
||||
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]);
|
||||
|
||||
const cancelRequest = async () => {
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const response = await axios.delete<MediaRequest>(
|
||||
`/api/v1/request/${activeRequest?.id}`
|
||||
`/api/v1/request/${editRequest?.id}`
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
@@ -177,12 +168,23 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
profileId: requestOverrides?.profile,
|
||||
rootFolder: requestOverrides?.folder,
|
||||
userId: requestOverrides?.user?.id,
|
||||
tags: requestOverrides?.tags,
|
||||
});
|
||||
|
||||
addToast(<span>{intl.formatMessage(messages.requestedited)}</span>, {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.requestedited, {
|
||||
title: data?.title,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(MediaStatus.PENDING);
|
||||
@@ -197,12 +199,15 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const isOwner = activeRequest
|
||||
? activeRequest.requestedBy.id === user?.id ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)
|
||||
: false;
|
||||
if (editRequest) {
|
||||
const isOwner = editRequest.requestedBy.id === user?.id;
|
||||
const showEditButton = hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_ADVANCED],
|
||||
{
|
||||
type: 'or',
|
||||
}
|
||||
);
|
||||
|
||||
if (activeRequest?.status === MediaRequestStatus.PENDING) {
|
||||
return (
|
||||
<Modal
|
||||
loading={!data && !error}
|
||||
@@ -210,47 +215,48 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
onCancel={onCancel}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.pending4krequest : messages.pendingrequest,
|
||||
{
|
||||
title: data?.title,
|
||||
}
|
||||
{ title: data?.title }
|
||||
)}
|
||||
onOk={() => updateRequest()}
|
||||
onOk={() => (showEditButton ? updateRequest() : cancelRequest())}
|
||||
okDisabled={isUpdating}
|
||||
okText={intl.formatMessage(globalMessages.edit)}
|
||||
okButtonType="primary"
|
||||
onSecondary={isOwner ? () => cancelRequest() : undefined}
|
||||
secondaryDisabled={isUpdating}
|
||||
secondaryText={
|
||||
isUpdating
|
||||
? intl.formatMessage(messages.cancelling)
|
||||
okText={
|
||||
showEditButton
|
||||
? intl.formatMessage(messages.edit)
|
||||
: intl.formatMessage(messages.cancel)
|
||||
}
|
||||
okButtonType={showEditButton ? 'primary' : 'danger'}
|
||||
onSecondary={
|
||||
isOwner && showEditButton ? () => cancelRequest() : undefined
|
||||
}
|
||||
secondaryDisabled={isUpdating}
|
||||
secondaryText={
|
||||
isOwner && showEditButton
|
||||
? intl.formatMessage(messages.cancel)
|
||||
: undefined
|
||||
}
|
||||
secondaryButtonType="danger"
|
||||
cancelText={intl.formatMessage(messages.close)}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
cancelText={intl.formatMessage(globalMessages.close)}
|
||||
iconSvg={<DownloadIcon />}
|
||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
is4k ? messages.request4kfrom : messages.requestfrom,
|
||||
{
|
||||
username: activeRequest.requestedBy.displayName,
|
||||
}
|
||||
)}
|
||||
{isOwner
|
||||
? intl.formatMessage(messages.pendingapproval)
|
||||
: intl.formatMessage(messages.requestfrom, {
|
||||
username: editRequest.requestedBy.displayName,
|
||||
})}
|
||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||
<div className="mt-4">
|
||||
<AdvancedRequester
|
||||
type="movie"
|
||||
is4k={is4k}
|
||||
requestUser={editRequest?.requestedBy}
|
||||
defaultOverrides={
|
||||
editRequest
|
||||
? {
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
requestUser={editRequest.requestedBy}
|
||||
defaultOverrides={{
|
||||
folder: editRequest.rootFolder,
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
tags: editRequest.tags,
|
||||
}}
|
||||
onChange={(overrides) => {
|
||||
setRequestOverrides(overrides);
|
||||
}}
|
||||
@@ -261,39 +267,55 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
loading={(!data && !error) || !quota}
|
||||
backgroundClickable
|
||||
onCancel={onCancel}
|
||||
onOk={sendRequest}
|
||||
okDisabled={isUpdating}
|
||||
okDisabled={isUpdating || quota?.movie.restricted}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||
{ title: data?.title }
|
||||
)}
|
||||
okText={
|
||||
isUpdating
|
||||
? intl.formatMessage(messages.requesting)
|
||||
: intl.formatMessage(is4k ? messages.request4k : messages.request)
|
||||
? intl.formatMessage(globalMessages.requesting)
|
||||
: intl.formatMessage(
|
||||
is4k ? globalMessages.request4k : globalMessages.request
|
||||
)
|
||||
}
|
||||
okButtonType={'primary'}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
iconSvg={<DownloadIcon />}
|
||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||
>
|
||||
{(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(
|
||||
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE
|
||||
) ||
|
||||
hasPermission(
|
||||
is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE
|
||||
)) && (
|
||||
<p className="mt-6">
|
||||
<Alert title={intl.formatMessage(messages.autoapproval)} type="info">
|
||||
{intl.formatMessage(messages.requestadmin)}
|
||||
</Alert>
|
||||
</p>
|
||||
{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}
|
||||
userOverride={
|
||||
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
||||
? requestOverrides?.user?.id
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||
|
||||
159
src/components/RequestModal/QuotaDisplay/index.tsx
Normal file
159
src/components/RequestModal/QuotaDisplay/index.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { QuotaStatus } from '../../../../server/interfaces/api/userInterfaces';
|
||||
import ProgressCircle from '../../Common/ProgressCircle';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestsremaining:
|
||||
'{remaining, plural, =0 {No} other {<strong>#</strong>}} {type} {remaining, plural, one {request} other {requests}} remaining',
|
||||
movielimit: '{limit, plural, one {movie} other {movies}}',
|
||||
seasonlimit: '{limit, plural, one {season} other {seasons}}',
|
||||
allowedRequests:
|
||||
'You are allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.',
|
||||
allowedRequestsUser:
|
||||
'This user is allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.',
|
||||
quotaLink:
|
||||
'You can view a summary of your request limits on your <ProfileLink>profile page</ProfileLink>.',
|
||||
quotaLinkUser:
|
||||
"You can view a summary of this user's request limits on their <ProfileLink>profile page</ProfileLink>.",
|
||||
movie: 'movie',
|
||||
season: 'season',
|
||||
notenoughseasonrequests: 'Not enough season requests remaining',
|
||||
requiredquota:
|
||||
'You need to have at least <strong>{seasons}</strong> {seasons, plural, one {season request} other {season requests}} remaining in order to submit a request for this series.',
|
||||
requiredquotaUser:
|
||||
'This user needs to have at least <strong>{seasons}</strong> {seasons, plural, one {season request} other {season requests}} remaining in order to submit a request for this series.',
|
||||
});
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota?: QuotaStatus;
|
||||
mediaType: 'movie' | 'tv';
|
||||
userOverride?: number | null;
|
||||
remaining?: number;
|
||||
overLimit?: number;
|
||||
}
|
||||
|
||||
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
quota,
|
||||
mediaType,
|
||||
userOverride,
|
||||
remaining,
|
||||
overLimit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col p-4 my-4 bg-gray-800 rounded-md"
|
||||
onClick={() => setShowDetails((s) => !s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setShowDetails((s) => !s);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ProgressCircle
|
||||
className="w-8 h-8"
|
||||
progress={Math.round(
|
||||
((remaining ?? quota?.remaining ?? 0) / (quota?.limit ?? 1)) * 100
|
||||
)}
|
||||
useHeatLevel
|
||||
/>
|
||||
<div
|
||||
className={`flex items-end ${
|
||||
(remaining ?? quota?.remaining ?? 0) <= 0 || quota?.restricted
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="ml-2 text-lg">
|
||||
{overLimit !== undefined
|
||||
? intl.formatMessage(messages.notenoughseasonrequests)
|
||||
: intl.formatMessage(messages.requestsremaining, {
|
||||
remaining: remaining ?? quota?.remaining ?? 0,
|
||||
type: intl.formatMessage(
|
||||
mediaType === 'movie' ? messages.movie : messages.season
|
||||
),
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-bold">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end flex-1">
|
||||
{showDetails ? (
|
||||
<ChevronUpIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDetails && (
|
||||
<div className="mt-4">
|
||||
{overLimit !== undefined && (
|
||||
<div className="mb-2">
|
||||
{intl.formatMessage(
|
||||
userOverride
|
||||
? messages.requiredquota
|
||||
: messages.requiredquotaUser,
|
||||
{
|
||||
seasons: overLimit,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-bold">{msg}</span>;
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{intl.formatMessage(
|
||||
userOverride
|
||||
? messages.allowedRequestsUser
|
||||
: messages.allowedRequests,
|
||||
{
|
||||
limit: quota?.limit,
|
||||
days: quota?.days,
|
||||
type: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.movielimit
|
||||
: messages.seasonlimit,
|
||||
{ limit: quota?.limit }
|
||||
),
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-bold">{msg}</span>;
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{intl.formatMessage(
|
||||
userOverride ? messages.quotaLinkUser : messages.quotaLink,
|
||||
{
|
||||
ProfileLink: function ProfileLink(msg) {
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
userOverride ? `/users/${userOverride}` : '/profile'
|
||||
}
|
||||
>
|
||||
<a className="text-white transition duration-300 hover:underline">
|
||||
{msg}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaDisplay;
|
||||
@@ -1,16 +1,16 @@
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import React from 'react';
|
||||
import Alert from '../../Common/Alert';
|
||||
import Modal from '../../Common/Modal';
|
||||
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
|
||||
import useSWR from 'swr';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { SonarrSeries } from '../../../../server/api/sonarr';
|
||||
import useSWR from 'swr';
|
||||
import { SonarrSeries } from '../../../../server/api/servarr/sonarr';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Alert from '../../Common/Alert';
|
||||
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
|
||||
import Modal from '../../Common/Modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
next: 'Next',
|
||||
notvdbid: 'Manual Match Required',
|
||||
notvdbiddescription:
|
||||
"We couldn't automatically match your request. Please select the correct match from the list below:",
|
||||
"We couldn't automatically match your request. Please select the correct match from the list below.",
|
||||
nosummary: 'No summary for this title was found.',
|
||||
});
|
||||
|
||||
@@ -49,29 +49,15 @@ const SearchByNameModal: React.FC<SearchByNameModalProps> = ({
|
||||
onCancel={onCancel}
|
||||
onOk={closeModal}
|
||||
title={modalTitle}
|
||||
okText={intl.formatMessage(messages.next)}
|
||||
okText={intl.formatMessage(globalMessages.next)}
|
||||
okDisabled={!tvdbId}
|
||||
okButtonType="primary"
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconSvg={<DownloadIcon />}
|
||||
>
|
||||
<Alert title={intl.formatMessage(messages.notvdbid)} type="info">
|
||||
{intl.formatMessage(messages.notvdbiddescription)}
|
||||
</Alert>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.notvdbiddescription)}
|
||||
type="info"
|
||||
/>
|
||||
{!data && !error && <SmallLoadingSpinner />}
|
||||
<div className="grid grid-cols-1 gap-4 pb-2 md:grid-cols-2">
|
||||
{data?.slice(0, 6).map((item) => (
|
||||
|
||||
@@ -1,49 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '../Common/Modal';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useSWR from 'swr';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
|
||||
import { DownloadIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import Badge from '../Common/Badge';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import SeasonRequest from '../../../server/entity/SeasonRequest';
|
||||
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Badge from '../Common/Badge';
|
||||
import Modal from '../Common/Modal';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import QuotaDisplay from './QuotaDisplay';
|
||||
import SearchByNameModal from './SearchByNameModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin: 'Your request will be immediately approved.',
|
||||
cancelrequest:
|
||||
'This will remove your request. Are you sure you want to continue?',
|
||||
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||
requestadmin: 'This request will be approved automatically.',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
requesttitle: 'Request {title}',
|
||||
request4ktitle: 'Request {title} in 4K',
|
||||
requesting: 'Requesting…',
|
||||
edit: 'Edit Request',
|
||||
cancel: 'Cancel Request',
|
||||
pendingrequest: 'Pending Request for {title}',
|
||||
pending4krequest: 'Pending 4K Request for {title}',
|
||||
requestfrom: "{username}'s request is pending approval.",
|
||||
requestseasons:
|
||||
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
|
||||
selectseason: 'Select season(s)',
|
||||
requestall: 'Request All Seasons',
|
||||
alreadyrequested: 'Already Requested',
|
||||
selectseason: 'Select Season(s)',
|
||||
season: 'Season',
|
||||
numberofepisodes: '# of Episodes',
|
||||
status: 'Status',
|
||||
seasonnumber: 'Season {number}',
|
||||
extras: 'Extras',
|
||||
notrequested: 'Not Requested',
|
||||
errorediting: 'Something went wrong while editing the request.',
|
||||
requestedited: 'Request edited.',
|
||||
requestcancelled: 'Request canceled.',
|
||||
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
|
||||
requestcancelled: 'Request for <strong>{title}</strong> canceled.',
|
||||
autoapproval: 'Automatic Approval',
|
||||
requesterror: 'Something went wrong while submitting the request.',
|
||||
next: 'Next',
|
||||
backbutton: 'Back',
|
||||
pendingapproval: 'Your request is pending approval.',
|
||||
});
|
||||
|
||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -63,6 +68,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
editRequest,
|
||||
is4k = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const { addToast } = useToasts();
|
||||
const editingSeasons: number[] = (editRequest?.seasons ?? []).map(
|
||||
(season) => season.seasonNumber
|
||||
@@ -76,13 +82,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
editRequest ? editingSeasons : []
|
||||
);
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { user, hasPermission } = useUser();
|
||||
const [searchModal, setSearchModal] = useState<{
|
||||
show: boolean;
|
||||
}>({
|
||||
show: true,
|
||||
});
|
||||
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
||||
);
|
||||
|
||||
const currentlyRemaining =
|
||||
(quota?.tv.remaining ?? 0) -
|
||||
selectedSeasons.length +
|
||||
(editRequest?.seasons ?? []).length;
|
||||
|
||||
const updateRequest = async () => {
|
||||
if (!editRequest) {
|
||||
@@ -102,6 +116,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
rootFolder: requestOverrides?.folder,
|
||||
languageProfileId: requestOverrides?.language,
|
||||
userId: requestOverrides?.user?.id,
|
||||
tags: requestOverrides?.tags,
|
||||
seasons: selectedSeasons,
|
||||
});
|
||||
} else {
|
||||
@@ -111,8 +126,18 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
addToast(
|
||||
<span>
|
||||
{selectedSeasons.length > 0
|
||||
? intl.formatMessage(messages.requestedited)
|
||||
: intl.formatMessage(messages.requestcancelled)}
|
||||
? intl.formatMessage(messages.requestedited, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})
|
||||
: intl.formatMessage(messages.requestcancelled, {
|
||||
title: data?.name,
|
||||
strong: function strong(msg) {
|
||||
return <strong>{msg}</strong>;
|
||||
},
|
||||
})}
|
||||
</span>,
|
||||
{
|
||||
appearance: 'success',
|
||||
@@ -135,9 +160,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
};
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (selectedSeasons.length === 0) {
|
||||
if (
|
||||
settings.currentSettings.partialRequestsEnabled &&
|
||||
selectedSeasons.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onUpdating) {
|
||||
onUpdating(true);
|
||||
}
|
||||
@@ -151,6 +180,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
rootFolder: requestOverrides.folder,
|
||||
languageProfileId: requestOverrides.language,
|
||||
userId: requestOverrides?.user?.id,
|
||||
tags: requestOverrides.tags,
|
||||
};
|
||||
}
|
||||
const response = await axios.post<MediaRequest>('/api/v1/request', {
|
||||
@@ -158,7 +188,11 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
|
||||
mediaType: 'tv',
|
||||
is4k,
|
||||
seasons: selectedSeasons,
|
||||
seasons: settings.currentSettings.partialRequestsEnabled
|
||||
? selectedSeasons
|
||||
: getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
),
|
||||
...overrideParams,
|
||||
});
|
||||
|
||||
@@ -190,6 +224,12 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getAllSeasons = (): number[] => {
|
||||
return (data?.seasons ?? [])
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.map((season) => season.seasonNumber);
|
||||
};
|
||||
|
||||
const getAllRequestedSeasons = (): number[] => {
|
||||
const requestedSeasons = (data?.mediaInfo?.requests ?? [])
|
||||
.filter(
|
||||
@@ -229,6 +269,15 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no more remaining requests available, block toggle
|
||||
if (
|
||||
quota?.tv.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedSeason(seasonNumber)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSeasons.includes(seasonNumber)) {
|
||||
setSelectedSeasons((seasons) =>
|
||||
seasons.filter((sn) => sn !== seasonNumber)
|
||||
@@ -238,25 +287,25 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const unrequestedSeasons = getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
);
|
||||
|
||||
const toggleAllSeasons = (): void => {
|
||||
// If the user has a quota and not enough requests for all seasons, block toggleAllSeasons
|
||||
if (
|
||||
quota?.tv.limit &&
|
||||
(quota?.tv.remaining ?? 0) < unrequestedSeasons.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data &&
|
||||
selectedSeasons.length >= 0 &&
|
||||
selectedSeasons.length <
|
||||
data?.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season.seasonNumber)
|
||||
).length
|
||||
selectedSeasons.length < unrequestedSeasons.length
|
||||
) {
|
||||
setSelectedSeasons(
|
||||
data.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season.seasonNumber)
|
||||
)
|
||||
.map((season) => season.seasonNumber)
|
||||
);
|
||||
setSelectedSeasons(unrequestedSeasons);
|
||||
} else {
|
||||
setSelectedSeasons([]);
|
||||
}
|
||||
@@ -268,11 +317,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
return (
|
||||
selectedSeasons.length ===
|
||||
data.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season.seasonNumber)
|
||||
).length
|
||||
getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
).length
|
||||
);
|
||||
};
|
||||
|
||||
@@ -303,6 +350,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
return seasonRequest;
|
||||
};
|
||||
|
||||
const isOwner = editRequest && editRequest.requestedBy.id === user?.id;
|
||||
|
||||
return !data?.externalIds.tvdbId && searchModal.show ? (
|
||||
<SearchByNameModal
|
||||
tvdbId={tvdbId}
|
||||
@@ -323,61 +372,110 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
onCancel={tvdbId ? () => setSearchModal({ show: true }) : onCancel}
|
||||
onOk={() => (editRequest ? updateRequest() : sendRequest())}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||
editRequest
|
||||
? is4k
|
||||
? messages.pending4krequest
|
||||
: messages.pendingrequest
|
||||
: is4k
|
||||
? messages.request4ktitle
|
||||
: messages.requesttitle,
|
||||
{ title: data?.name }
|
||||
)}
|
||||
okText={
|
||||
editRequest && selectedSeasons.length === 0
|
||||
? 'Cancel Request'
|
||||
editRequest
|
||||
? selectedSeasons.length === 0
|
||||
? intl.formatMessage(messages.cancel)
|
||||
: intl.formatMessage(messages.edit)
|
||||
: getAllRequestedSeasons().length >= getAllSeasons().length
|
||||
? intl.formatMessage(messages.alreadyrequested)
|
||||
: !settings.currentSettings.partialRequestsEnabled
|
||||
? intl.formatMessage(messages.requestall)
|
||||
: selectedSeasons.length === 0
|
||||
? intl.formatMessage(messages.selectseason)
|
||||
: intl.formatMessage(messages.requestseasons, {
|
||||
seasonCount: selectedSeasons.length,
|
||||
})
|
||||
}
|
||||
okDisabled={editRequest ? false : selectedSeasons.length === 0}
|
||||
okDisabled={
|
||||
editRequest
|
||||
? false
|
||||
: !settings.currentSettings.partialRequestsEnabled &&
|
||||
quota?.tv.limit &&
|
||||
unrequestedSeasons.length > quota.tv.limit
|
||||
? true
|
||||
: getAllRequestedSeasons().length >= getAllSeasons().length ||
|
||||
(settings.currentSettings.partialRequestsEnabled &&
|
||||
selectedSeasons.length === 0)
|
||||
}
|
||||
okButtonType={
|
||||
editRequest && selectedSeasons.length === 0 ? 'danger' : `primary`
|
||||
editRequest &&
|
||||
settings.currentSettings.partialRequestsEnabled &&
|
||||
selectedSeasons.length === 0
|
||||
? 'danger'
|
||||
: `primary`
|
||||
}
|
||||
cancelText={
|
||||
tvdbId
|
||||
? intl.formatMessage(messages.backbutton)
|
||||
editRequest
|
||||
? intl.formatMessage(globalMessages.close)
|
||||
: tvdbId
|
||||
? intl.formatMessage(globalMessages.back)
|
||||
: intl.formatMessage(globalMessages.cancel)
|
||||
}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconSvg={<DownloadIcon />}
|
||||
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
|
||||
>
|
||||
{(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(
|
||||
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE
|
||||
) ||
|
||||
hasPermission(
|
||||
is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV
|
||||
)) &&
|
||||
{editRequest
|
||||
? isOwner
|
||||
? intl.formatMessage(messages.pendingapproval)
|
||||
: intl.formatMessage(messages.requestfrom, {
|
||||
username: editRequest?.requestedBy.displayName,
|
||||
})
|
||||
: null}
|
||||
{hasPermission(
|
||||
[
|
||||
Permission.MANAGE_REQUESTS,
|
||||
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
|
||||
is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
!(
|
||||
quota?.tv.limit &&
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
) &&
|
||||
getAllRequestedSeasons().length < getAllSeasons().length &&
|
||||
!editRequest && (
|
||||
<p className="mt-6">
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.autoapproval)}
|
||||
title={intl.formatMessage(messages.requestadmin)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(messages.requestadmin)}
|
||||
</Alert>
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{(quota?.tv.limit ?? 0) > 0 && (
|
||||
<QuotaDisplay
|
||||
mediaType="tv"
|
||||
quota={quota?.tv}
|
||||
remaining={
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
? 0
|
||||
: currentlyRemaining
|
||||
}
|
||||
userOverride={
|
||||
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
||||
? requestOverrides?.user?.id
|
||||
: undefined
|
||||
}
|
||||
overLimit={
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
? unrequestedSeasons.length
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="-mx-4 sm:mx-0">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
@@ -385,7 +483,12 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-16 px-4 py-3 bg-gray-500">
|
||||
<th
|
||||
className={`w-16 px-4 py-3 bg-gray-500 ${
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
'hidden'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
@@ -396,7 +499,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
toggleAllSeasons();
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none"
|
||||
className={`relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${
|
||||
quota?.tv.remaining &&
|
||||
quota.tv.limit &&
|
||||
quota.tv.remaining < unrequestedSeasons.length
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -419,7 +528,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
{intl.formatMessage(messages.numberofepisodes)}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
|
||||
{intl.formatMessage(messages.status)}
|
||||
{intl.formatMessage(globalMessages.status)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -438,7 +547,12 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
);
|
||||
return (
|
||||
<tr key={`season-${season.id}`}>
|
||||
<td className="px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
|
||||
<td
|
||||
className={`px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap ${
|
||||
!settings.currentSettings
|
||||
.partialRequestsEnabled && 'hidden'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
@@ -458,6 +572,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}}
|
||||
className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||
mediaSeason ||
|
||||
(quota?.tv.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedSeason(season.seasonNumber)) ||
|
||||
(!!seasonRequest &&
|
||||
!editingSeasons.includes(season.seasonNumber))
|
||||
? 'opacity-50'
|
||||
@@ -505,7 +622,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
|
||||
{!seasonRequest && !mediaSeason && (
|
||||
<Badge>
|
||||
{intl.formatMessage(messages.notrequested)}
|
||||
{intl.formatMessage(
|
||||
globalMessages.notrequested
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
{!mediaSeason &&
|
||||
@@ -566,6 +685,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
profile: editRequest.profileId,
|
||||
server: editRequest.serverId,
|
||||
language: editRequest.languageProfileId,
|
||||
tags: editRequest.tags,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
import Button from '../Common/Button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { ArrowLeftIcon, MailIcon } 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, useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
import Button from '../Common/Button';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
|
||||
const messages = defineMessages({
|
||||
forgotpassword: 'Forgot Your Password?',
|
||||
emailresetlink: 'Email Me a Recovery Link',
|
||||
email: 'Email',
|
||||
passwordreset: 'Password Reset',
|
||||
resetpassword: 'Reset your password',
|
||||
emailresetlink: 'Email Recovery Link',
|
||||
email: 'Email Address',
|
||||
validationemailrequired: 'You must provide a valid email address',
|
||||
gobacklogin: 'Go Back to Sign-In Page',
|
||||
gobacklogin: 'Return to Sign-In Page',
|
||||
requestresetlinksuccessmessage:
|
||||
'A password reset link will be sent to the provided email address if it is associated with a valid user.',
|
||||
});
|
||||
@@ -30,7 +33,9 @@ const ResetPassword: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
|
||||
<PageTitle title={intl.formatMessage(messages.passwordreset)} />
|
||||
<ImageFader
|
||||
forceOptimize
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
@@ -43,14 +48,10 @@ const ResetPassword: React.FC = () => {
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img
|
||||
src="/logo.png"
|
||||
className="w-auto mx-auto max-h-32"
|
||||
alt="Overseerr Logo"
|
||||
/>
|
||||
<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">
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
{intl.formatMessage(messages.resetpassword)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
@@ -67,7 +68,8 @@ const ResetPassword: React.FC = () => {
|
||||
<span className="flex justify-center mt-4 rounded-md shadow-sm">
|
||||
<Link href="/login" passHref>
|
||||
<Button as="a" buttonType="ghost">
|
||||
{intl.formatMessage(messages.gobacklogin)}
|
||||
<ArrowLeftIcon />
|
||||
<span>{intl.formatMessage(messages.gobacklogin)}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
@@ -94,7 +96,7 @@ const ResetPassword: React.FC = () => {
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
@@ -102,12 +104,12 @@ const ResetPassword: React.FC = () => {
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="name@example.com"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +126,10 @@ const ResetPassword: React.FC = () => {
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{intl.formatMessage(messages.emailresetlink)}
|
||||
<MailIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.emailresetlink)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import React, { useState } from 'react';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
import Button from '../Common/Button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { SupportIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Button from '../Common/Button';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import SensitiveInput from '../Common/SensitiveInput';
|
||||
import LanguagePicker from '../Layout/LanguagePicker';
|
||||
|
||||
const messages = defineMessages({
|
||||
resetpassword: 'Reset Password',
|
||||
passwordreset: 'Password Reset',
|
||||
resetpassword: 'Reset your password',
|
||||
password: 'Password',
|
||||
confirmpassword: 'Confirm Password',
|
||||
validationpasswordrequired: 'You must provide a password',
|
||||
validationpasswordmatch: 'Password must match',
|
||||
validationpasswordmatch: 'Passwords must match',
|
||||
validationpasswordminchars:
|
||||
'Password is too short; should be a minimum of 8 characters',
|
||||
gobacklogin: 'Go Back to Sign-In Page',
|
||||
resetpasswordsuccessmessage:
|
||||
'If the link is valid and is connected to a user then the password has been reset.',
|
||||
gobacklogin: 'Return to Sign-In Page',
|
||||
resetpasswordsuccessmessage: 'Password reset successfully!',
|
||||
});
|
||||
|
||||
const ResetPassword: React.FC = () => {
|
||||
@@ -47,6 +50,7 @@ const ResetPassword: React.FC = () => {
|
||||
return (
|
||||
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
|
||||
<ImageFader
|
||||
forceOptimize
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
@@ -59,12 +63,8 @@ const ResetPassword: React.FC = () => {
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img
|
||||
src="/logo.png"
|
||||
className="w-auto mx-auto max-h-32"
|
||||
alt="Overseerr Logo"
|
||||
/>
|
||||
<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">
|
||||
{intl.formatMessage(messages.resetpassword)}
|
||||
</h2>
|
||||
@@ -111,7 +111,7 @@ const ResetPassword: React.FC = () => {
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
@@ -119,14 +119,13 @@ const ResetPassword: React.FC = () => {
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.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"
|
||||
/>
|
||||
</div>
|
||||
@@ -141,12 +140,13 @@ const ResetPassword: React.FC = () => {
|
||||
{intl.formatMessage(messages.confirmpassword)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm 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"
|
||||
/>
|
||||
</div>
|
||||
@@ -166,7 +166,12 @@ const ResetPassword: React.FC = () => {
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{intl.formatMessage(messages.resetpassword)}
|
||||
<SupportIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,71 +1,46 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
TvResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
} from '../../../server/models/Search';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import ListView from '../Common/ListView';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Header from '../Common/Header';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import Error from '../../pages/_error';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
|
||||
const messages = defineMessages({
|
||||
search: 'Search',
|
||||
searchresults: 'Search Results',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (MovieResult | TvResult | PersonResult)[];
|
||||
}
|
||||
|
||||
const Search: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const router = useRouter();
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/search/?query=${router.query.query}&page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
titles,
|
||||
fetchMore,
|
||||
error,
|
||||
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||
`/api/v1/search`,
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
query: router.query.query,
|
||||
},
|
||||
{ hideAvailable: false }
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <Error statusCode={error.code} />;
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const titles = data?.reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.search)} />
|
||||
|
||||
49
src/components/ServiceWorkerSetup/index.tsx
Normal file
49
src/components/ServiceWorkerSetup/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable no-console */
|
||||
import axios from 'axios';
|
||||
import React, { useEffect } from 'react';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
|
||||
const ServiceWorkerSetup: React.FC = () => {
|
||||
const { currentSettings } = useSettings();
|
||||
const { user } = useUser();
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator && user?.id) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then(async (registration) => {
|
||||
console.log(
|
||||
'[SW] Registration successful, scope is:',
|
||||
registration.scope
|
||||
);
|
||||
|
||||
if (currentSettings.enablePushRegistration) {
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: currentSettings.vapidPublic,
|
||||
});
|
||||
|
||||
const parsedSub = JSON.parse(JSON.stringify(sub));
|
||||
|
||||
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
|
||||
await axios.post('/api/v1/user/registerPushSubscription', {
|
||||
endpoint: parsedSub.endpoint,
|
||||
p256dh: parsedSub.keys.p256dh,
|
||||
auth: parsedSub.keys.auth,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.log('[SW] Service worker registration failed, error:', error);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
currentSettings.vapidPublic,
|
||||
currentSettings.enablePushRegistration,
|
||||
]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ServiceWorkerSetup;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ClipboardCopyIcon } from '@heroicons/react/solid';
|
||||
import React, { useEffect } from 'react';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
|
||||
const messages = defineMessages({
|
||||
copied: 'Copied API key to clipboard.',
|
||||
@@ -29,17 +30,9 @@ const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => {
|
||||
e.preventDefault();
|
||||
setCopied();
|
||||
}}
|
||||
className="-ml-px relative inline-flex items-center px-4 py-2 border border-gray-500 text-sm leading-5 font-medium text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150"
|
||||
className="input-action"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" />
|
||||
<path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5zM15 11h2a1 1 0 110 2h-2v-2z" />
|
||||
</svg>
|
||||
<ClipboardCopyIcon />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/solid';
|
||||
import React from 'react';
|
||||
|
||||
interface LibraryItemProps {
|
||||
@@ -12,8 +13,8 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<li className="col-span-1 flex shadow-sm rounded-md">
|
||||
<div className="flex-1 flex items-center justify-between border-t border-r border-b border-gray-700 bg-gray-600 rounded-md truncate">
|
||||
<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">
|
||||
{name}
|
||||
</div>
|
||||
@@ -45,19 +46,7 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
|
||||
: 'opacity-100 ease-in duration-200'
|
||||
} absolute inset-0 h-full w-full flex items-center justify-center transition-opacity`}
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path
|
||||
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<XIcon className="w-3 h-3 text-gray-400" />
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
@@ -66,13 +55,7 @@ const LibraryItem: React.FC<LibraryItemProps> = ({
|
||||
: 'opacity-0 ease-out duration-100'
|
||||
} absolute inset-0 h-full w-full flex items-center justify-center transition-opacity`}
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3 text-indigo-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
|
||||
</svg>
|
||||
<CheckIcon className="w-3 h-3 text-indigo-600" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,39 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Button from '../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
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({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
botUsername: 'Bot Username',
|
||||
botAvatarUrl: 'Bot Avatar URL',
|
||||
webhookUrl: 'Webhook URL',
|
||||
webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks',
|
||||
webhookUrlTip:
|
||||
'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server',
|
||||
discordsettingssaved: 'Discord notification settings saved successfully!',
|
||||
discordsettingsfailed: 'Discord notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
notificationtypes: 'Notification Types',
|
||||
validationWebhookUrl: 'You must provide a valid URL',
|
||||
toastDiscordTestSending: 'Sending Discord test notification…',
|
||||
toastDiscordTestSuccess: 'Discord test notification sent!',
|
||||
toastDiscordTestFailed: 'Discord test notification failed to send.',
|
||||
validationUrl: 'You must provide a valid URL',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
});
|
||||
|
||||
const NotificationsDiscord: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/discord'
|
||||
);
|
||||
|
||||
const NotificationsDiscordSchema = Yup.object().shape({
|
||||
botAvatarUrl: Yup.string()
|
||||
.nullable()
|
||||
.url(intl.formatMessage(messages.validationUrl)),
|
||||
webhookUrl: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl))
|
||||
.url(intl.formatMessage(messages.validationWebhookUrl)),
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationUrl)),
|
||||
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) {
|
||||
@@ -45,6 +66,8 @@ const NotificationsDiscord: React.FC = () => {
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
botUsername: data?.options.botUsername,
|
||||
botAvatarUrl: data?.options.botAvatarUrl,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
}}
|
||||
validationSchema={NotificationsDiscordSchema}
|
||||
@@ -54,9 +77,12 @@ const NotificationsDiscord: React.FC = () => {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
botUsername: values.botUsername,
|
||||
botAvatarUrl: values.botAvatarUrl,
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.discordsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
@@ -71,20 +97,57 @@ const NotificationsDiscord: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/discord/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastDiscordTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/discord/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
botUsername: values.botUsername,
|
||||
botAvatarUrl: values.botAvatarUrl,
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastDiscordTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastDiscordTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,6 +155,7 @@ const NotificationsDiscord: React.FC = () => {
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
@@ -100,16 +164,31 @@ const NotificationsDiscord: React.FC = () => {
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.webhookUrlTip, {
|
||||
DiscordWebhookLink: function DiscordWebhookLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.webhookUrlPlaceholder
|
||||
)}
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
@@ -117,49 +196,84 @@ const NotificationsDiscord: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botUsername" className="text-label">
|
||||
{intl.formatMessage(messages.botUsername)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="botUsername" name="botUsername" type="text" />
|
||||
</div>
|
||||
{errors.botUsername && touched.botUsername && (
|
||||
<div className="error">{errors.botUsername}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botAvatarUrl" className="text-label">
|
||||
{intl.formatMessage(messages.botAvatarUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="botAvatarUrl"
|
||||
name="botAvatarUrl"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.botAvatarUrl && touched.botAvatarUrl && (
|
||||
<div className="error">{errors.botAvatarUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
<BeakerIcon />
|
||||
<span>
|
||||
{isTesting
|
||||
? intl.formatMessage(globalMessages.testing)
|
||||
: intl.formatMessage(globalMessages.test)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,61 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Button from '../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import NotificationTypeSelector from '../../NotificationTypeSelector';
|
||||
import Alert from '../../Common/Alert';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Badge from '../../Common/Badge';
|
||||
import Button from '../../Common/Button';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import SensitiveInput from '../../Common/SensitiveInput';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
validationSmtpHostRequired: 'You must provide an SMTP host',
|
||||
validationSmtpPortRequired: 'You must provide an SMTP port',
|
||||
validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
|
||||
validationSmtpPortRequired: 'You must provide a valid port number',
|
||||
agentenabled: 'Enable Agent',
|
||||
emailsender: 'Sender Address',
|
||||
smtpHost: 'SMTP Host',
|
||||
smtpPort: 'SMTP Port',
|
||||
enableSsl: 'Enable SSL',
|
||||
encryption: 'Encryption Method',
|
||||
encryptionTip:
|
||||
'In most cases, Implicit TLS uses port 465 and STARTTLS uses port 587',
|
||||
encryptionNone: 'None',
|
||||
encryptionDefault: 'Use STARTTLS if available',
|
||||
encryptionOpportunisticTls: 'Always use STARTTLS',
|
||||
encryptionImplicitTls: 'Use Implicit TLS',
|
||||
authUser: 'SMTP Username',
|
||||
authPass: 'SMTP Password',
|
||||
emailsettingssaved: 'Email notification settings saved successfully!',
|
||||
emailsettingsfailed: 'Email notification settings failed to save.',
|
||||
test: 'Test',
|
||||
testsent: 'Test notification sent!',
|
||||
toastEmailTestSending: 'Sending email test notification…',
|
||||
toastEmailTestSuccess: 'Email test notification sent!',
|
||||
toastEmailTestFailed: 'Email test notification failed to send.',
|
||||
allowselfsigned: 'Allow Self-Signed Certificates',
|
||||
ssldisabletip:
|
||||
'SSL should be disabled on standard TLS connections (port 587)',
|
||||
senderName: 'Sender Name',
|
||||
notificationtypes: 'Notification Types',
|
||||
validationEmail: 'You must provide a valid email address',
|
||||
emailNotificationTypesAlert: 'Notification Email Recipients',
|
||||
emailNotificationTypesAlertDescription:
|
||||
'For the "Media Requested" and "Media Failed" notification types,\
|
||||
notifications will only be sent to users with the "Manage Requests" permission.',
|
||||
pgpPrivateKey: 'PGP Private Key',
|
||||
pgpPrivateKeyTip:
|
||||
'Sign encrypted email messages using <OpenPgpLink>OpenPGP</OpenPgpLink>',
|
||||
validationPgpPrivateKey: 'You must provide a valid PGP private key',
|
||||
pgpPassword: 'PGP Password',
|
||||
pgpPasswordTip:
|
||||
'Sign encrypted email messages using <OpenPgpLink>OpenPGP</OpenPgpLink>',
|
||||
validationPgpPassword: 'You must provide a PGP password',
|
||||
});
|
||||
|
||||
export function OpenPgpLink(msg: string): JSX.Element {
|
||||
return (
|
||||
<a href="https://www.openpgp.org/" target="_blank" rel="noreferrer">
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const NotificationsEmail: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/email'
|
||||
);
|
||||
|
||||
const NotificationsEmailSchema = Yup.object().shape({
|
||||
emailFrom: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationEmail))
|
||||
.email(intl.formatMessage(messages.validationEmail)),
|
||||
smtpHost: Yup.string().required(
|
||||
intl.formatMessage(messages.validationSmtpHostRequired)
|
||||
),
|
||||
smtpPort: Yup.number().required(
|
||||
intl.formatMessage(messages.validationSmtpPortRequired)
|
||||
),
|
||||
});
|
||||
const NotificationsEmailSchema = Yup.object().shape(
|
||||
{
|
||||
emailFrom: Yup.string()
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationEmail)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.email(intl.formatMessage(messages.validationEmail)),
|
||||
smtpHost: Yup.string()
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationSmtpHostRequired)),
|
||||
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.validationSmtpHostRequired)
|
||||
),
|
||||
smtpPort: Yup.number().when('enabled', {
|
||||
is: true,
|
||||
then: Yup.number()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationSmtpPortRequired)),
|
||||
otherwise: Yup.number().nullable(),
|
||||
}),
|
||||
pgpPrivateKey: Yup.string()
|
||||
.when('pgpPassword', {
|
||||
is: (value: unknown) => !!value,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationPgpPrivateKey)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
/-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/s,
|
||||
intl.formatMessage(messages.validationPgpPrivateKey)
|
||||
),
|
||||
pgpPassword: Yup.string().when('pgpPrivateKey', {
|
||||
is: (value: unknown) => !!value,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationPgpPassword)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
}),
|
||||
},
|
||||
[['pgpPrivateKey', 'pgpPassword']]
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
@@ -65,33 +123,45 @@ const NotificationsEmail: React.FC = () => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
emailFrom: data.options.emailFrom,
|
||||
smtpHost: data.options.smtpHost,
|
||||
smtpPort: data.options.smtpPort,
|
||||
secure: data.options.secure,
|
||||
smtpPort: data.options.smtpPort ?? 587,
|
||||
encryption: data.options.secure
|
||||
? 'implicit'
|
||||
: data.options.requireTls
|
||||
? 'opportunistic'
|
||||
: data.options.ignoreTls
|
||||
? 'none'
|
||||
: 'default',
|
||||
authUser: data.options.authUser,
|
||||
authPass: data.options.authPass,
|
||||
allowSelfSigned: data.options.allowSelfSigned,
|
||||
senderName: data.options.senderName,
|
||||
pgpPrivateKey: data.options.pgpPrivateKey,
|
||||
pgpPassword: data.options.pgpPassword,
|
||||
}}
|
||||
validationSchema={NotificationsEmailSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/email', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
smtpPort: Number(values.smtpPort),
|
||||
secure: values.secure,
|
||||
secure: values.encryption === 'implicit',
|
||||
ignoreTls: values.encryption === 'none',
|
||||
requireTls: values.encryption === 'opportunistic',
|
||||
authUser: values.authUser,
|
||||
authPass: values.authPass,
|
||||
allowSelfSigned: values.allowSelfSigned,
|
||||
senderName: values.senderName,
|
||||
pgpPrivateKey: values.pgpPrivateKey,
|
||||
pgpPassword: values.pgpPassword,
|
||||
},
|
||||
});
|
||||
mutate('/api/v1/settings/public');
|
||||
|
||||
addToast(intl.formatMessage(messages.emailsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
@@ -106,216 +176,293 @@ const NotificationsEmail: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
|
||||
{({ errors, touched, isSubmitting, values, isValid }) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/email/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
smtpPort: Number(values.smtpPort),
|
||||
secure: values.secure,
|
||||
authUser: values.authUser,
|
||||
authPass: values.authPass,
|
||||
senderName: values.senderName,
|
||||
},
|
||||
});
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastEmailTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/email/test', {
|
||||
enabled: true,
|
||||
options: {
|
||||
emailFrom: values.emailFrom,
|
||||
smtpHost: values.smtpHost,
|
||||
smtpPort: Number(values.smtpPort),
|
||||
secure: values.encryption === 'implicit',
|
||||
ignoreTls: values.encryption === 'none',
|
||||
requireTls: values.encryption === 'opportunistic',
|
||||
authUser: values.authUser,
|
||||
authPass: values.authPass,
|
||||
senderName: values.senderName,
|
||||
pgpPrivateKey: values.pgpPrivateKey,
|
||||
pgpPassword: values.pgpPassword,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastEmailTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastEmailTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.emailNotificationTypesAlert)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(
|
||||
messages.emailNotificationTypesAlertDescription
|
||||
)}
|
||||
</Alert>
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
<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">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="senderName" className="text-label">
|
||||
{intl.formatMessage(messages.senderName)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="senderName" name="senderName" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="emailFrom" className="text-label">
|
||||
{intl.formatMessage(messages.emailsender)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="emailFrom"
|
||||
name="emailFrom"
|
||||
type="text"
|
||||
placeholder="no-reply@example.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.emailFrom && touched.emailFrom && (
|
||||
<div className="error">{errors.emailFrom}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="senderName" className="text-label">
|
||||
{intl.formatMessage(messages.senderName)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="senderName"
|
||||
name="senderName"
|
||||
placeholder="Overseerr"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="smtpHost" className="text-label">
|
||||
{intl.formatMessage(messages.smtpHost)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="smtpHost"
|
||||
name="smtpHost"
|
||||
type="text"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
{errors.smtpHost && touched.smtpHost && (
|
||||
<div className="error">{errors.smtpHost}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="smtpPort" className="text-label">
|
||||
{intl.formatMessage(messages.smtpPort)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm sm:max-w-xs">
|
||||
<Field
|
||||
id="smtpPort"
|
||||
name="smtpPort"
|
||||
type="text"
|
||||
placeholder="465"
|
||||
className="short"
|
||||
/>
|
||||
</div>
|
||||
{errors.smtpPort && touched.smtpPort && (
|
||||
<div className="error">{errors.smtpPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="secure" className="checkbox-label">
|
||||
<span>{intl.formatMessage(messages.enableSsl)}</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.ssldisabletip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="secure" name="secure" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="allowSelfSigned" className="checkbox-label">
|
||||
{intl.formatMessage(messages.allowselfsigned)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="emailFrom" className="text-label">
|
||||
{intl.formatMessage(messages.emailsender)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="allowSelfSigned"
|
||||
name="allowSelfSigned"
|
||||
id="emailFrom"
|
||||
name="emailFrom"
|
||||
type="text"
|
||||
inputMode="email"
|
||||
/>
|
||||
</div>
|
||||
{errors.emailFrom && touched.emailFrom && (
|
||||
<div className="error">{errors.emailFrom}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="smtpHost" className="text-label">
|
||||
{intl.formatMessage(messages.smtpHost)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="smtpHost"
|
||||
name="smtpHost"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.smtpHost && touched.smtpHost && (
|
||||
<div className="error">{errors.smtpHost}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="smtpPort" className="text-label">
|
||||
{intl.formatMessage(messages.smtpPort)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
id="smtpPort"
|
||||
name="smtpPort"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="short"
|
||||
/>
|
||||
{errors.smtpPort && touched.smtpPort && (
|
||||
<div className="error">{errors.smtpPort}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="encryption" className="text-label">
|
||||
{intl.formatMessage(messages.encryption)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.encryptionTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field as="select" id="encryption" name="encryption">
|
||||
<option value="none">
|
||||
{intl.formatMessage(messages.encryptionNone)}
|
||||
</option>
|
||||
<option value="default">
|
||||
{intl.formatMessage(messages.encryptionDefault)}
|
||||
</option>
|
||||
<option value="opportunistic">
|
||||
{intl.formatMessage(messages.encryptionOpportunisticTls)}
|
||||
</option>
|
||||
<option value="implicit">
|
||||
{intl.formatMessage(messages.encryptionImplicitTls)}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="allowSelfSigned" className="checkbox-label">
|
||||
{intl.formatMessage(messages.allowselfsigned)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="allowSelfSigned"
|
||||
name="allowSelfSigned"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="authUser" className="text-label">
|
||||
{intl.formatMessage(messages.authUser)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="authUser" name="authUser" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="authPass" className="text-label">
|
||||
{intl.formatMessage(messages.authPass)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="authPass"
|
||||
name="authPass"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="authUser" className="text-label">
|
||||
{intl.formatMessage(messages.authUser)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field id="authUser" name="authUser" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="pgpPrivateKey" className="text-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.pgpPrivateKey)}
|
||||
</span>
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.advanced)}
|
||||
</Badge>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.pgpPrivateKeyTip, {
|
||||
OpenPgpLink: OpenPgpLink,
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="pgpPrivateKey"
|
||||
name="pgpPrivateKey"
|
||||
type="textarea"
|
||||
rows="10"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{errors.pgpPrivateKey && touched.pgpPrivateKey && (
|
||||
<div className="error">{errors.pgpPrivateKey}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="authPass" className="text-label">
|
||||
{intl.formatMessage(messages.authPass)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="authPass"
|
||||
name="authPass"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="pgpPassword" className="text-label">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.pgpPassword)}
|
||||
</span>
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(globalMessages.advanced)}
|
||||
</Badge>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.pgpPasswordTip, {
|
||||
OpenPgpLink: OpenPgpLink,
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="pgpPassword"
|
||||
name="pgpPassword"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{errors.pgpPassword && touched.pgpPassword && (
|
||||
<div className="error">{errors.pgpPassword}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 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="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
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',
|
||||
webhookUrl: 'Webhook URL',
|
||||
webhookUrlTip:
|
||||
'Your user- or device-based <LunaSeaLink>notification webhook URL</LunaSeaLink>',
|
||||
validationWebhookUrl: 'You must provide a valid URL',
|
||||
profileName: 'Profile Name',
|
||||
profileNameTip: 'Only required if not using the <code>default</code> profile',
|
||||
settingsSaved: 'LunaSea notification settings saved successfully!',
|
||||
settingsFailed: 'LunaSea notification settings failed to save.',
|
||||
toastLunaSeaTestSending: 'Sending LunaSea test notification…',
|
||||
toastLunaSeaTestSuccess: 'LunaSea test notification sent!',
|
||||
toastLunaSeaTestFailed: 'LunaSea test notification failed to send.',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
});
|
||||
|
||||
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 NotificationsLunaSeaSchema = Yup.object().shape({
|
||||
webhookUrl: Yup.string()
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl)),
|
||||
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) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
profileName: data.options.profileName,
|
||||
}}
|
||||
validationSchema={NotificationsLunaSeaSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/lunasea', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
profileName: values.profileName,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.settingsSaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.settingsFailed), {
|
||||
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.toastLunaSeaTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/lunasea/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
profileName: values.profileName,
|
||||
},
|
||||
});
|
||||
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastLunaSeaTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastLunaSeaTestFailed), {
|
||||
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">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.webhookUrlTip, {
|
||||
LunaSeaLink: function LunaSeaLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://docs.lunasea.app/lunasea/notifications/overseerr"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="error">{errors.webhookUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="profileName" className="text-label">
|
||||
{intl.formatMessage(messages.profileName)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.profileNameTip, {
|
||||
code: function code(msg) {
|
||||
return <code className="bg-opacity-50">{msg}</code>;
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="profileName" name="profileName" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 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="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsLunaSea;
|
||||
@@ -1,43 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Alert from '../../../Common/Alert';
|
||||
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 SensitiveInput from '../../../Common/SensitiveInput';
|
||||
import NotificationTypeSelector from '../../../NotificationTypeSelector';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentEnabled: 'Enable Agent',
|
||||
accessToken: 'Access Token',
|
||||
accessTokenTip:
|
||||
'Create a token from your <PushbulletSettingsLink>Account Settings</PushbulletSettingsLink>',
|
||||
validationAccessTokenRequired: 'You must provide an access token',
|
||||
pushbulletSettingsSaved:
|
||||
'Pushbullet notification settings saved successfully!',
|
||||
pushbulletSettingsFailed: 'Pushbullet notification settings failed to save.',
|
||||
testSent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
settingUpPushbullet: 'Setting Up Pushbullet Notifications',
|
||||
settingUpPushbulletDescription:
|
||||
'To configure Pushbullet notifications, you will need to <CreateAccessTokenLink>create an access token</CreateAccessTokenLink> and enter it below.',
|
||||
notificationTypes: 'Notification Types',
|
||||
toastPushbulletTestSending: 'Sending Pushbullet test notification…',
|
||||
toastPushbulletTestSuccess: 'Pushbullet test notification sent!',
|
||||
toastPushbulletTestFailed: 'Pushbullet test notification failed to send.',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
});
|
||||
|
||||
const NotificationsPushbullet: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/pushbullet'
|
||||
);
|
||||
|
||||
const NotificationsPushbulletSchema = Yup.object().shape({
|
||||
accessToken: Yup.string().required(
|
||||
intl.formatMessage(messages.validationAccessTokenRequired)
|
||||
),
|
||||
accessToken: Yup.string().when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.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) {
|
||||
@@ -75,121 +87,157 @@ const NotificationsPushbullet: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/pushbullet/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
accessToken: values.accessToken,
|
||||
},
|
||||
});
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastPushbulletTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/pushbullet/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
accessToken: values.accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testSent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastPushbulletTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastPushbulletTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.settingUpPushbullet)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(messages.settingUpPushbulletDescription, {
|
||||
CreateAccessTokenLink: function CreateAccessTokenLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://www.pushbullet.com/#settings"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentEnabled)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<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">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="accessToken" className="text-label">
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="accessToken"
|
||||
name="accessToken"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.accessToken)}
|
||||
/>
|
||||
</div>
|
||||
{errors.accessToken && touched.accessToken && (
|
||||
<div className="error">{errors.accessToken}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="accessToken" className="text-label">
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.accessTokenTip, {
|
||||
PushbulletSettingsLink: function PushbulletSettingsLink(
|
||||
msg
|
||||
) {
|
||||
return (
|
||||
<a
|
||||
href="https://www.pushbullet.com/#settings/account"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="accessToken"
|
||||
name="accessToken"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{errors.accessToken && touched.accessToken && (
|
||||
<div className="error">{errors.accessToken}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationTypes)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 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="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
@@ -1,55 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Alert from '../../../Common/Alert';
|
||||
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({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
accessToken: 'Application/API Token',
|
||||
userToken: 'User Key',
|
||||
accessToken: 'Application API Token',
|
||||
accessTokenTip:
|
||||
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Overseerr',
|
||||
userToken: 'User or Group Key',
|
||||
userTokenTip:
|
||||
'Your 30-character <UsersGroupsLink>user or group identifier</UsersGroupsLink>',
|
||||
validationAccessTokenRequired: 'You must provide a valid application token',
|
||||
validationUserTokenRequired: 'You must provide a valid user key',
|
||||
validationUserTokenRequired: 'You must provide a valid user or group key',
|
||||
pushoversettingssaved: 'Pushover notification settings saved successfully!',
|
||||
pushoversettingsfailed: 'Pushover notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
settinguppushover: 'Setting Up Pushover Notifications',
|
||||
settinguppushoverDescription:
|
||||
'To configure Pushover notifications, you will need to <RegisterApplicationLink>register an application</RegisterApplicationLink> and enter the API token below.\
|
||||
(You can use one of our <IconLink>official icons on GitHub</IconLink>.)\
|
||||
You will also need your user key.',
|
||||
notificationtypes: 'Notification Types',
|
||||
toastPushoverTestSending: 'Sending Pushover test notification…',
|
||||
toastPushoverTestSuccess: 'Pushover test notification sent!',
|
||||
toastPushoverTestFailed: 'Pushover test notification failed to send.',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
});
|
||||
|
||||
const NotificationsPushover: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/pushover'
|
||||
);
|
||||
|
||||
const NotificationsPushoverSchema = Yup.object().shape({
|
||||
accessToken: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationAccessTokenRequired))
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationAccessTokenRequired)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
/^a[A-Za-z0-9]{29}$/,
|
||||
/^[a-z\d]{30}$/i,
|
||||
intl.formatMessage(messages.validationAccessTokenRequired)
|
||||
),
|
||||
userToken: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationUserTokenRequired))
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationUserTokenRequired)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
/^[ug][A-Za-z0-9]{29}$/,
|
||||
/^[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) {
|
||||
@@ -89,152 +108,183 @@ const NotificationsPushover: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/pushover/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
accessToken: values.accessToken,
|
||||
userToken: values.userToken,
|
||||
},
|
||||
});
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastPushoverTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/pushover/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
accessToken: values.accessToken,
|
||||
userToken: values.userToken,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastPushoverTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastPushoverTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.settinguppushover)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(messages.settinguppushoverDescription, {
|
||||
RegisterApplicationLink: function RegisterApplicationLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://pushover.net/apps/build"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
IconLink: function IconLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://github.com/sct/overseerr/tree/develop/public"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<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">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="accessToken" className="text-label">
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="accessToken"
|
||||
name="accessToken"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.accessToken)}
|
||||
/>
|
||||
</div>
|
||||
{errors.accessToken && touched.accessToken && (
|
||||
<div className="error">{errors.accessToken}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="accessToken" className="text-label">
|
||||
{intl.formatMessage(messages.accessToken)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.accessTokenTip, {
|
||||
ApplicationRegistrationLink: function ApplicationRegistrationLink(
|
||||
msg
|
||||
) {
|
||||
return (
|
||||
<a
|
||||
href="https://pushover.net/api#registration"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="accessToken" name="accessToken" type="text" />
|
||||
</div>
|
||||
{errors.accessToken && touched.accessToken && (
|
||||
<div className="error">{errors.accessToken}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="userToken" className="text-label">
|
||||
{intl.formatMessage(messages.userToken)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="userToken"
|
||||
name="userToken"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.userToken)}
|
||||
/>
|
||||
</div>
|
||||
{errors.userToken && touched.userToken && (
|
||||
<div className="error">{errors.userToken}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="userToken" className="text-label">
|
||||
{intl.formatMessage(messages.userToken)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.userTokenTip, {
|
||||
UsersGroupsLink: function UsersGroupsLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://pushover.net/api#identifiers"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="userToken" name="userToken" type="text" />
|
||||
</div>
|
||||
{errors.userToken && touched.userToken && (
|
||||
<div className="error">{errors.userToken}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 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="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
@@ -1,42 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Alert from '../../../Common/Alert';
|
||||
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({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
webhookUrl: 'Webhook URL',
|
||||
webhookUrlTip:
|
||||
'Create an <WebhookLink>Incoming Webhook</WebhookLink> integration',
|
||||
slacksettingssaved: 'Slack notification settings saved successfully!',
|
||||
slacksettingsfailed: 'Slack notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
settingupslack: 'Setting Up Slack Notifications',
|
||||
settingupslackDescription:
|
||||
'To configure Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and enter the webhook URL below.',
|
||||
notificationtypes: 'Notification Types',
|
||||
toastSlackTestSending: 'Sending Slack test notification…',
|
||||
toastSlackTestSuccess: 'Slack test notification sent!',
|
||||
toastSlackTestFailed: 'Slack test notification failed to send.',
|
||||
validationWebhookUrl: 'You must provide a valid URL',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
});
|
||||
|
||||
const NotificationsSlack: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/slack'
|
||||
);
|
||||
|
||||
const NotificationsSlackSchema = Yup.object().shape({
|
||||
webhookUrl: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl))
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl)),
|
||||
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) {
|
||||
@@ -44,62 +57,59 @@ const NotificationsSlack: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert title={intl.formatMessage(messages.settingupslack)} type="info">
|
||||
{intl.formatMessage(messages.settingupslackDescription, {
|
||||
WebhookLink: function WebhookLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://my.slack.com/services/new/incoming-webhook/"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
}}
|
||||
validationSchema={NotificationsSlackSchema}
|
||||
onSubmit={async (values) => {
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
}}
|
||||
validationSchema={NotificationsSlackSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/slack', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.slacksettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.slacksettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const testSettings = async () => {
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/slack', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastSlackTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.slacksettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.slacksettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
}) => {
|
||||
const testSettings = async () => {
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/slack/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
@@ -108,89 +118,127 @@ const NotificationsSlack: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastSlackTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
};
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastSlackTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="isDefault" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="isDefault" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field id="webhookUrl" name="webhookUrl" type="text" />
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="error">{errors.webhookUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.webhookUrlTip, {
|
||||
WebhookLink: function WebhookLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://my.slack.com/services/new/incoming-webhook/"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="error">{errors.webhookUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 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="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,51 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Button from '../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Alert from '../../Common/Alert';
|
||||
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 SensitiveInput from '../../Common/SensitiveInput';
|
||||
import NotificationTypeSelector from '../../NotificationTypeSelector';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
botAPI: 'Bot Authentication Token',
|
||||
botUsername: 'Bot Username',
|
||||
botUsernameTip:
|
||||
'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 Overseerr',
|
||||
chatId: 'Chat ID',
|
||||
validationBotAPIRequired: 'You must provide a bot authentication token',
|
||||
validationChatIdRequired: 'You must provide a chat ID',
|
||||
chatIdTip:
|
||||
'Start a chat with your bot, add <GetIdBotLink>@get_id_bot</GetIdBotLink>, and issue the <code>/my_id</code> command',
|
||||
validationBotAPIRequired: 'You must provide a bot authorization token',
|
||||
validationChatIdRequired: 'You must provide a valid chat ID',
|
||||
telegramsettingssaved: 'Telegram notification settings saved successfully!',
|
||||
telegramsettingsfailed: 'Telegram notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
settinguptelegram: 'Setting Up Telegram Notifications',
|
||||
settinguptelegramDescription:
|
||||
'To configure Telegram notifications, you will need to <CreateBotLink>create a bot</CreateBotLink> and get the bot API key.\
|
||||
Additionally, you will need the chat ID for the chat to which you would like to send notifications.\
|
||||
You can get this by adding <GetIdBotLink>@get_id_bot</GetIdBotLink> to the chat.',
|
||||
notificationtypes: 'Notification Types',
|
||||
toastTelegramTestSending: 'Sending Telegram test notification…',
|
||||
toastTelegramTestSuccess: 'Telegram test notification sent!',
|
||||
toastTelegramTestFailed: 'Telegram test notification failed to send.',
|
||||
sendSilently: 'Send Silently',
|
||||
sendSilentlyTip: 'Send notifications with no sound',
|
||||
});
|
||||
|
||||
const NotificationsTelegram: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/telegram'
|
||||
);
|
||||
|
||||
const NotificationsTelegramSchema = Yup.object().shape({
|
||||
botAPI: Yup.string().required(
|
||||
intl.formatMessage(messages.validationBotAPIRequired)
|
||||
),
|
||||
chatId: Yup.string().required(
|
||||
intl.formatMessage(messages.validationChatIdRequired)
|
||||
),
|
||||
botAPI: Yup.string().when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationBotAPIRequired)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
}),
|
||||
chatId: Yup.string()
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationChatIdRequired)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
/^-?\d+$/,
|
||||
intl.formatMessage(messages.validationChatIdRequired)
|
||||
),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -57,6 +73,7 @@ const NotificationsTelegram: React.FC = () => {
|
||||
initialValues={{
|
||||
enabled: data?.enabled,
|
||||
types: data?.types,
|
||||
botUsername: data?.options.botUsername,
|
||||
botAPI: data?.options.botAPI,
|
||||
chatId: data?.options.chatId,
|
||||
sendSilently: data?.options.sendSilently,
|
||||
@@ -71,8 +88,10 @@ const NotificationsTelegram: React.FC = () => {
|
||||
botAPI: values.botAPI,
|
||||
chatId: values.chatId,
|
||||
sendSilently: values.sendSilently,
|
||||
botUsername: values.botUsername,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.telegramsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
@@ -87,168 +106,232 @@ const NotificationsTelegram: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/telegram/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
botAPI: values.botAPI,
|
||||
chatId: values.chatId,
|
||||
sendSilently: values.sendSilently,
|
||||
},
|
||||
});
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastTelegramTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/telegram/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
botAPI: values.botAPI,
|
||||
chatId: values.chatId,
|
||||
sendSilently: values.sendSilently,
|
||||
botUsername: values.botUsername,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastTelegramTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastTelegramTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.settinguptelegram)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(messages.settinguptelegramDescription, {
|
||||
CreateBotLink: function CreateBotLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://core.telegram.org/bots#6-botfather"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
GetIdBotLink: function GetIdBotLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://telegram.me/get_id_bot"
|
||||
className="text-indigo-100 hover:text-white hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<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">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botAPI" className="text-label">
|
||||
{intl.formatMessage(messages.botAPI)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="botAPI"
|
||||
name="botAPI"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.botAPI)}
|
||||
/>
|
||||
</div>
|
||||
{errors.botAPI && touched.botAPI && (
|
||||
<div className="error">{errors.botAPI}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="chatId" className="text-label">
|
||||
{intl.formatMessage(messages.chatId)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="chatId"
|
||||
name="chatId"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.chatId)}
|
||||
/>
|
||||
</div>
|
||||
{errors.chatId && touched.chatId && (
|
||||
<div className="error">{errors.chatId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="sendSilently" className="checkbox-label">
|
||||
<span>{intl.formatMessage(messages.sendSilently)}</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.sendSilentlyTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="sendSilently"
|
||||
name="sendSilently"
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botAPI" className="text-label">
|
||||
{intl.formatMessage(messages.botAPI)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.botApiTip, {
|
||||
CreateBotLink: function CreateBotLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://core.telegram.org/bots#6-botfather"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
GetIdBotLink: function GetIdBotLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://telegram.me/get_id_bot"
|
||||
className="text-white transition duration-300 hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: function code(msg) {
|
||||
return <code className="bg-opacity-50">{msg}</code>;
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="botAPI"
|
||||
name="botAPI"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
{errors.botAPI && touched.botAPI && (
|
||||
<div className="error">{errors.botAPI}</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="botUsername" className="text-label">
|
||||
{intl.formatMessage(messages.botUsername)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.botUsernameTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="botUsername" name="botUsername" type="text" />
|
||||
</div>
|
||||
{errors.botUsername && touched.botUsername && (
|
||||
<div className="error">{errors.botUsername}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="chatId" className="text-label">
|
||||
{intl.formatMessage(messages.chatId)}
|
||||
<span className="label-required">*</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.chatIdTip, {
|
||||
GetIdBotLink: function GetIdBotLink(msg) {
|
||||
return (
|
||||
<a
|
||||
href="https://telegram.me/get_id_bot"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{msg}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: function code(msg) {
|
||||
return <code>{msg}</code>;
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field id="chatId" name="chatId" type="text" />
|
||||
</div>
|
||||
{errors.chatId && touched.chatId && (
|
||||
<div className="error">{errors.chatId}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="sendSilently" className="checkbox-label">
|
||||
<span>{intl.formatMessage(messages.sendSilently)}</span>
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.sendSilentlyTip)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="sendSilently" name="sendSilently" />
|
||||
</div>
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 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="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
import Alert from '../../../Common/Alert';
|
||||
import Button from '../../../Common/Button';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
|
||||
const messages = defineMessages({
|
||||
agentenabled: 'Enable Agent',
|
||||
webpushsettingssaved: 'Web push notification settings saved successfully!',
|
||||
webpushsettingsfailed: 'Web push notification settings failed to save.',
|
||||
toastWebPushTestSending: 'Sending web push test notification…',
|
||||
toastWebPushTestSuccess: 'Web push test notification sent!',
|
||||
toastWebPushTestFailed: 'Web push test notification failed to send.',
|
||||
httpsRequirement:
|
||||
'In order to receive web push notifications, Overseerr must be served over HTTPS.',
|
||||
});
|
||||
|
||||
const NotificationsWebPush: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isHttps, setIsHttps] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/webpush'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHttps(window.location.protocol.startsWith('https'));
|
||||
}, []);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isHttps && (
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.httpsRequirement)}
|
||||
type="warning"
|
||||
/>
|
||||
)}
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/webpush', {
|
||||
enabled: values.enabled,
|
||||
options: {},
|
||||
});
|
||||
mutate('/api/v1/settings/public');
|
||||
addToast(intl.formatMessage(messages.webpushsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.webpushsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting }) => {
|
||||
const testSettings = async () => {
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastWebPushTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/webpush/test', {
|
||||
enabled: true,
|
||||
options: {},
|
||||
});
|
||||
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastWebPushTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastWebPushTestFailed), {
|
||||
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">
|
||||
<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">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || isTesting}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
<BeakerIcon />
|
||||
<span>
|
||||
{isTesting
|
||||
? intl.formatMessage(globalMessages.testing)
|
||||
: intl.formatMessage(globalMessages.test)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWebPush;
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
|
||||
import { QuestionMarkCircleIcon, RefreshIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import dynamic from 'next/dynamic';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
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 JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
|
||||
@@ -31,45 +35,60 @@ const defaultPayload = {
|
||||
'{{extra}}': [],
|
||||
'{{request}}': {
|
||||
request_id: '{{request_id}}',
|
||||
requestedBy_email: '{{requestedBy_email}}',
|
||||
requestedBy_username: '{{requestedBy_username}}',
|
||||
requestedBy_avatar: '{{requestedBy_avatar}}',
|
||||
},
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
agentenabled: 'Enable Agent',
|
||||
webhookUrl: 'Webhook URL',
|
||||
authheader: 'Authorization Header',
|
||||
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
|
||||
webhooksettingssaved: 'Webhook notification settings saved successfully!',
|
||||
webhooksettingsfailed: 'Webhook notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
notificationtypes: 'Notification Types',
|
||||
toastWebhookTestSending: 'Sending webhook test notification…',
|
||||
toastWebhookTestSuccess: 'Webhook test notification sent!',
|
||||
toastWebhookTestFailed: 'Webhook test notification failed to send.',
|
||||
resetPayload: 'Reset to Default',
|
||||
resetPayloadSuccess: 'JSON payload reset successfully!',
|
||||
customJson: 'JSON Payload',
|
||||
templatevariablehelp: 'Template Variable Help',
|
||||
validationWebhookUrl: 'You must provide a valid URL',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
});
|
||||
|
||||
const NotificationsWebhook: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { addToast, removeToast } = useToasts();
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/webhook'
|
||||
);
|
||||
|
||||
const NotificationsWebhookSchema = Yup.object().shape({
|
||||
webhookUrl: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl))
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationWebhookUrl)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.matches(
|
||||
// eslint-disable-next-line
|
||||
// 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.validationWebhookUrl)
|
||||
),
|
||||
jsonPayload: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationJsonPayloadRequired))
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: Yup.string()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationJsonPayloadRequired)),
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.test(
|
||||
'validate-json',
|
||||
intl.formatMessage(messages.validationJsonPayloadRequired),
|
||||
@@ -82,6 +101,13 @@ 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) {
|
||||
@@ -144,20 +170,47 @@ const NotificationsWebhook: React.FC = () => {
|
||||
};
|
||||
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/webhook/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||
authHeader: values.authHeader,
|
||||
},
|
||||
});
|
||||
setIsTesting(true);
|
||||
let toastId: string | undefined;
|
||||
try {
|
||||
addToast(
|
||||
intl.formatMessage(messages.toastWebhookTestSending),
|
||||
{
|
||||
autoDismiss: false,
|
||||
appearance: 'info',
|
||||
},
|
||||
(id) => {
|
||||
toastId = id;
|
||||
}
|
||||
);
|
||||
await axios.post('/api/v1/settings/notifications/webhook/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||
authHeader: values.authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastWebhookTestSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
if (toastId) {
|
||||
removeToast(toastId);
|
||||
}
|
||||
addToast(intl.formatMessage(messages.toastWebhookTestFailed), {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -165,18 +218,25 @@ const NotificationsWebhook: React.FC = () => {
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
<label htmlFor="webhookUrl" className="text-label">
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field id="webhookUrl" name="webhookUrl" type="text" />
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="error">{errors.webhookUrl}</div>
|
||||
@@ -184,21 +244,22 @@ const NotificationsWebhook: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
<label htmlFor="authHeader" className="text-label">
|
||||
{intl.formatMessage(messages.authheader)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field id="authHeader" name="authHeader" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="text-label">
|
||||
<label htmlFor="webhook-json-payload" className="text-label">
|
||||
{intl.formatMessage(messages.customJson)}
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<JSONEditor
|
||||
name="webhook-json-payload"
|
||||
onUpdate={(value) => setFieldValue('jsonPayload', value)}
|
||||
@@ -218,92 +279,75 @@ const NotificationsWebhook: React.FC = () => {
|
||||
}}
|
||||
className="mr-2"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.resetPayload)}
|
||||
<RefreshIcon />
|
||||
<span>{intl.formatMessage(messages.resetPayload)}</span>
|
||||
</Button>
|
||||
<a
|
||||
<Link
|
||||
href="https://docs.overseerr.dev/using-overseerr/notifications/webhooks#template-variables"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-transparent rounded-md focus:outline-none hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50 px-2.5 py-1.5 text-xs"
|
||||
passHref
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<Button
|
||||
as="a"
|
||||
buttonSize="sm"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.templatevariablehelp)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="sm:grid sm:grid-cols-4 sm:gap-4">
|
||||
<div>
|
||||
<div id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QuestionMarkCircleIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.templatevariablehelp)}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
setFieldValue('types', newTypes);
|
||||
setFieldTouched('types');
|
||||
|
||||
if (newTypes) {
|
||||
setFieldValue('enabled', true);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
errors.types && touched.types
|
||||
? (errors.types as string)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
<BeakerIcon />
|
||||
<span>
|
||||
{isTesting
|
||||
? intl.formatMessage(globalMessages.testing)
|
||||
: intl.formatMessage(globalMessages.test)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
disabled={isSubmitting || !isValid || isTesting}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Transition from '../../Transition';
|
||||
import Modal from '../../Common/Modal';
|
||||
import { Formik, Field } from 'formik';
|
||||
import type { RadarrSettings } from '../../../../server/lib/settings';
|
||||
import * as Yup from 'yup';
|
||||
import { PencilIcon, PlusIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
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 { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
import type { RadarrSettings } from '../../../../server/lib/settings';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Modal from '../../Common/Modal';
|
||||
import SensitiveInput from '../../Common/SensitiveInput';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
type OptionType = {
|
||||
value: string;
|
||||
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/IP',
|
||||
validationPortRequired: 'You must provide a port',
|
||||
validationHostnameRequired: 'You must provide a 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',
|
||||
validationProfileRequired: 'You must select a profile',
|
||||
validationMinimumAvailabilityRequired: 'You must select minimum availability',
|
||||
toastRadarrTestSuccess: 'Radarr connection established!',
|
||||
validationProfileRequired: 'You must select a quality profile',
|
||||
validationMinimumAvailabilityRequired:
|
||||
'You must select a minimum availability',
|
||||
toastRadarrTestSuccess: 'Radarr connection established successfully!',
|
||||
toastRadarrTestFailure: 'Failed to connect to Radarr.',
|
||||
saving: 'Saving…',
|
||||
save: 'Save Changes',
|
||||
add: 'Add Server',
|
||||
test: 'Test',
|
||||
testing: 'Testing…',
|
||||
defaultserver: 'Default Server',
|
||||
default4kserver: 'Default 4K Server',
|
||||
servername: 'Server Name',
|
||||
servernamePlaceholder: 'A Radarr Server',
|
||||
hostname: 'Hostname',
|
||||
hostname: 'Hostname or IP Address',
|
||||
port: 'Port',
|
||||
ssl: 'SSL',
|
||||
ssl: 'Use SSL',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'Your Radarr API key',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlPlaceholder: 'Example: /radarr',
|
||||
syncEnabled: 'Enable Sync',
|
||||
baseUrl: 'URL Base',
|
||||
syncEnabled: 'Enable Scan',
|
||||
externalUrl: 'External URL',
|
||||
externalUrlPlaceholder: 'External URL pointing to your Radarr server',
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
minimumAvailability: 'Minimum Availability',
|
||||
@@ -49,11 +57,16 @@ const messages = defineMessages({
|
||||
testFirstQualityProfiles: 'Test connection to load quality profiles',
|
||||
loadingrootfolders: 'Loading root folders…',
|
||||
testFirstRootFolders: 'Test connection to load root folders',
|
||||
preventSearch: 'Disable Auto-Search',
|
||||
loadingTags: 'Loading tags…',
|
||||
testFirstTags: 'Test connection to load tags',
|
||||
tags: 'Tags',
|
||||
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',
|
||||
notagoptions: 'No tags.',
|
||||
selecttags: 'Select tags',
|
||||
});
|
||||
|
||||
interface TestResponse {
|
||||
@@ -65,6 +78,10 @@ interface TestResponse {
|
||||
id: number;
|
||||
path: string;
|
||||
}[];
|
||||
tags: {
|
||||
id: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface RadarrModalProps {
|
||||
@@ -86,17 +103,21 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
||||
profiles: [],
|
||||
rootFolders: [],
|
||||
tags: [],
|
||||
});
|
||||
const RadarrSettingsSchema = Yup.object().shape({
|
||||
name: Yup.string().required(
|
||||
intl.formatMessage(messages.validationNameRequired)
|
||||
),
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
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,
|
||||
intl.formatMessage(messages.validationHostnameRequired)
|
||||
),
|
||||
port: Yup.number()
|
||||
.nullable()
|
||||
.required(intl.formatMessage(messages.validationPortRequired)),
|
||||
apiKey: Yup.string().required(
|
||||
intl.formatMessage(messages.validationApiKeyRequired)
|
||||
),
|
||||
@@ -114,33 +135,18 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||
(value) => {
|
||||
if (value?.substr(value.length - 1) === '/') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
baseUrl: Yup.string()
|
||||
.test(
|
||||
'leading-slash',
|
||||
intl.formatMessage(messages.validationBaseUrlLeadingSlash),
|
||||
(value) => {
|
||||
if (value && value?.substr(0, 1) !== '/') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(value) => !value || value.startsWith('/')
|
||||
)
|
||||
.test(
|
||||
'no-trailing-slash',
|
||||
intl.formatMessage(messages.validationBaseUrlTrailingSlash),
|
||||
(value) => {
|
||||
if (value?.substr(value.length - 1) === '/') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(value) => !value || !value.endsWith('/')
|
||||
),
|
||||
});
|
||||
|
||||
@@ -192,7 +198,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
initialLoad.current = true;
|
||||
}
|
||||
},
|
||||
[addToast]
|
||||
[addToast, intl]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -222,18 +228,19 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
initialValues={{
|
||||
name: radarr?.name,
|
||||
hostname: radarr?.hostname,
|
||||
port: radarr?.port,
|
||||
port: radarr?.port ?? 7878,
|
||||
ssl: radarr?.useSsl ?? false,
|
||||
apiKey: radarr?.apiKey,
|
||||
baseUrl: radarr?.baseUrl,
|
||||
activeProfileId: radarr?.activeProfileId,
|
||||
rootFolder: radarr?.activeDirectory,
|
||||
minimumAvailability: radarr?.minimumAvailability ?? 'released',
|
||||
tags: radarr?.tags ?? [],
|
||||
isDefault: radarr?.isDefault ?? false,
|
||||
is4k: radarr?.is4k ?? false,
|
||||
externalUrl: radarr?.externalUrl,
|
||||
syncEnabled: radarr?.syncEnabled,
|
||||
preventSearch: radarr?.preventSearch,
|
||||
syncEnabled: radarr?.syncEnabled ?? false,
|
||||
enableSearch: !radarr?.preventSearch,
|
||||
}}
|
||||
validationSchema={RadarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
@@ -254,10 +261,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
activeDirectory: values.rootFolder,
|
||||
is4k: values.is4k,
|
||||
minimumAvailability: values.minimumAvailability,
|
||||
tags: values.tags,
|
||||
isDefault: values.isDefault,
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: values.preventSearch,
|
||||
preventSearch: !values.enableSearch,
|
||||
};
|
||||
if (!radarr) {
|
||||
await axios.post('/api/v1/settings/radarr', submission);
|
||||
@@ -289,16 +297,16 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
okButtonType="primary"
|
||||
okText={
|
||||
isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: radarr
|
||||
? intl.formatMessage(messages.save)
|
||||
? intl.formatMessage(globalMessages.save)
|
||||
: intl.formatMessage(messages.add)
|
||||
}
|
||||
secondaryButtonType="warning"
|
||||
secondaryText={
|
||||
isTesting
|
||||
? intl.formatMessage(messages.testing)
|
||||
: intl.formatMessage(messages.test)
|
||||
? intl.formatMessage(globalMessages.testing)
|
||||
: intl.formatMessage(globalMessages.test)
|
||||
}
|
||||
onSecondary={() => {
|
||||
if (values.apiKey && values.hostname && values.port) {
|
||||
@@ -312,20 +320,35 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
}
|
||||
}}
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
!values.apiKey ||
|
||||
!values.hostname ||
|
||||
!values.port ||
|
||||
isTesting ||
|
||||
isSubmitting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!radarr
|
||||
? intl.formatMessage(messages.createradarr)
|
||||
: intl.formatMessage(messages.editradarr)
|
||||
? intl.formatMessage(
|
||||
values.is4k
|
||||
? messages.create4kradarr
|
||||
: messages.createradarr
|
||||
)
|
||||
: intl.formatMessage(
|
||||
values.is4k ? messages.edit4kradarr : messages.editradarr
|
||||
)
|
||||
}
|
||||
iconSvg={!radarr ? <PlusIcon /> : <PencilIcon />}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="form-row">
|
||||
<label htmlFor="isDefault" className="checkbox-label">
|
||||
{intl.formatMessage(messages.defaultserver)}
|
||||
{intl.formatMessage(
|
||||
values.is4k
|
||||
? messages.default4kserver
|
||||
: messages.defaultserver
|
||||
)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="isDefault" name="isDefault" />
|
||||
@@ -345,14 +368,11 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.servernamePlaceholder
|
||||
)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsValidated(false);
|
||||
setFieldValue('name', e.target.value);
|
||||
@@ -370,7 +390,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<span className="protocol">
|
||||
{values.ssl ? 'https://' : 'http://'}
|
||||
</span>
|
||||
@@ -378,7 +398,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
type="text"
|
||||
placeholder="127.0.0.1"
|
||||
inputMode="url"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsValidated(false);
|
||||
setFieldValue('hostname', e.target.value);
|
||||
@@ -401,7 +421,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
id="port"
|
||||
name="port"
|
||||
type="text"
|
||||
placeholder="7878"
|
||||
inputMode="numeric"
|
||||
className="short"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsValidated(false);
|
||||
@@ -435,14 +455,12 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
as="field"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.apiKeyPlaceholder
|
||||
)}
|
||||
autoComplete="one-time-code"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsValidated(false);
|
||||
setFieldValue('apiKey', e.target.value);
|
||||
@@ -459,14 +477,12 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
{intl.formatMessage(messages.baseUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="baseUrl"
|
||||
name="baseUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.baseUrlPlaceholder
|
||||
)}
|
||||
inputMode="url"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsValidated(false);
|
||||
setFieldValue('baseUrl', e.target.value);
|
||||
@@ -484,7 +500,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="activeProfileId"
|
||||
@@ -522,7 +538,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="rootFolder"
|
||||
@@ -558,7 +574,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
<span className="label-required">*</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
as="select"
|
||||
id="minimumAvailability"
|
||||
@@ -578,19 +594,68 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tags" className="text-label">
|
||||
{intl.formatMessage(messages.tags)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Select
|
||||
options={
|
||||
isValidated
|
||||
? testResponse.tags.map((tag) => ({
|
||||
label: tag.label,
|
||||
value: tag.id,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
isMulti
|
||||
isDisabled={!isValidated || isTesting}
|
||||
placeholder={
|
||||
!isValidated
|
||||
? intl.formatMessage(messages.testFirstTags)
|
||||
: isTesting
|
||||
? intl.formatMessage(messages.loadingTags)
|
||||
: intl.formatMessage(messages.selecttags)
|
||||
}
|
||||
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;
|
||||
}
|
||||
setFieldValue(
|
||||
'tags',
|
||||
value?.map((option) => option.value)
|
||||
);
|
||||
}}
|
||||
noOptionsMessage={() =>
|
||||
intl.formatMessage(messages.notagoptions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="externalUrl" className="text-label">
|
||||
{intl.formatMessage(messages.externalUrl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="externalUrl"
|
||||
name="externalUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.externalUrlPlaceholder
|
||||
)}
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.externalUrl && touched.externalUrl && (
|
||||
@@ -611,14 +676,14 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="preventSearch" className="checkbox-label">
|
||||
{intl.formatMessage(messages.preventSearch)}
|
||||
<label htmlFor="enableSearch" className="checkbox-label">
|
||||
{intl.formatMessage(messages.enableSearch)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="preventSearch"
|
||||
name="preventSearch"
|
||||
id="enableSearch"
|
||||
name="enableSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { DocumentTextIcon } from '@heroicons/react/outline';
|
||||
import React, { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
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';
|
||||
import Modal from '../../../Common/Modal';
|
||||
import Transition from '../../../Transition';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
|
||||
const messages = defineMessages({
|
||||
releases: 'Releases',
|
||||
@@ -18,9 +19,8 @@ const messages = defineMessages({
|
||||
latestversion: 'Latest',
|
||||
currentversion: 'Current Version',
|
||||
viewchangelog: 'View Changelog',
|
||||
runningDevelop: 'You are running a develop version of Overseerr!',
|
||||
runningDevelopMessage:
|
||||
'The changes in your version will not be available below. Please see the <GithubLink>GitHub repository</GithubLink> for latest updates.',
|
||||
'The latest changes to the <code>develop</code> branch of Overseerr are not shown below. Please see the commit history for this branch on <GithubLink>GitHub</GithubLink> for details.',
|
||||
});
|
||||
|
||||
const REPO_RELEASE_API =
|
||||
@@ -71,22 +71,7 @@ const Release: React.FC<ReleaseProps> = ({
|
||||
>
|
||||
<Modal
|
||||
onCancel={() => setModalOpen(false)}
|
||||
iconSvg={
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
iconSvg={<DocumentTextIcon />}
|
||||
title={intl.formatMessage(messages.versionChangelog)}
|
||||
cancelText={intl.formatMessage(globalMessages.close)}
|
||||
okText={intl.formatMessage(messages.viewongithub)}
|
||||
@@ -106,10 +91,10 @@ const Release: React.FC<ReleaseProps> = ({
|
||||
(new Date(release.created_at).getTime() - Date.now()) / 1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="always"
|
||||
numeric="auto"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-lg">{release.name}</span>
|
||||
<span className="text-lg font-bold">{release.name}</span>
|
||||
{isLatest && (
|
||||
<span className="ml-2 -mt-1">
|
||||
<Badge badgeType="primary">
|
||||
@@ -127,7 +112,8 @@ const Release: React.FC<ReleaseProps> = ({
|
||||
</div>
|
||||
<div className="flex-1 text-center sm:text-right">
|
||||
<Button buttonType="primary" onClick={() => setModalOpen(true)}>
|
||||
{intl.formatMessage(messages.viewchangelog)}
|
||||
<DocumentTextIcon />
|
||||
<span>{intl.formatMessage(messages.viewchangelog)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,8 +145,11 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
|
||||
<h3 className="heading">{intl.formatMessage(messages.releases)}</h3>
|
||||
<div className="section">
|
||||
{currentVersion.startsWith('develop-') && (
|
||||
<Alert title={intl.formatMessage(messages.runningDevelop)}>
|
||||
{intl.formatMessage(messages.runningDevelopMessage, {
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.runningDevelopMessage, {
|
||||
code: function code(msg) {
|
||||
return <code className="bg-opacity-50">{msg}</code>;
|
||||
},
|
||||
GithubLink: function GithubLink(msg) {
|
||||
return (
|
||||
<a
|
||||
@@ -174,7 +163,7 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Alert>
|
||||
/>
|
||||
)}
|
||||
{data?.map((release, index) => {
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import { InformationCircleIcon } from '@heroicons/react/solid';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SettingsAboutResponse,
|
||||
StatusResponse,
|
||||
} from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Error from '../../../pages/_error';
|
||||
import Badge from '../../Common/Badge';
|
||||
import List from '../../Common/List';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import { SettingsAboutResponse } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import Releases from './Releases';
|
||||
import Badge from '../../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
about: 'About',
|
||||
overseerrinformation: 'Overseerr Information',
|
||||
version: 'Version',
|
||||
totalmedia: 'Total Media',
|
||||
totalrequests: 'Total Requests',
|
||||
gettingsupport: 'Getting Support',
|
||||
githubdiscussions: 'GitHub Discussions',
|
||||
clickheretojoindiscord: 'Click here to join our Discord server.',
|
||||
timezone: 'Timezone',
|
||||
timezone: 'Time Zone',
|
||||
supportoverseerr: 'Support Overseerr',
|
||||
helppaycoffee: 'Help Pay for Coffee',
|
||||
documentation: 'Documentation',
|
||||
preferredmethod: 'Preferred',
|
||||
outofdate: 'Out of Date',
|
||||
uptodate: 'Up to Date',
|
||||
betawarning:
|
||||
'This is BETA software. Features may be broken and/or unstable. Please report any issues on GitHub!',
|
||||
});
|
||||
|
||||
const SettingsAbout: React.FC = () => {
|
||||
@@ -29,6 +39,8 @@ const SettingsAbout: React.FC = () => {
|
||||
'/api/v1/settings/about'
|
||||
);
|
||||
|
||||
const { data: status } = useSWR<StatusResponse>('/api/v1/status');
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -39,20 +51,62 @@ const SettingsAbout: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.about),
|
||||
intl.formatMessage(globalMessages.settings),
|
||||
]}
|
||||
/>
|
||||
<div className="p-4 mt-6 bg-indigo-700 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 ml-3 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="http://github.com/sct/overseerr"
|
||||
className="font-medium text-indigo-100 transition duration-150 ease-in-out whitespace-nowrap hover:text-white"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<List title={intl.formatMessage(messages.overseerrinformation)}>
|
||||
<List.Item title={intl.formatMessage(messages.version)}>
|
||||
{data.version}
|
||||
<List.Item
|
||||
title={intl.formatMessage(messages.version)}
|
||||
className="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>
|
||||
)
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item title={intl.formatMessage(messages.totalmedia)}>
|
||||
<FormattedNumber value={data.totalMediaItems} />
|
||||
{intl.formatNumber(data.totalMediaItems)}
|
||||
</List.Item>
|
||||
<List.Item title={intl.formatMessage(messages.totalrequests)}>
|
||||
<FormattedNumber value={data.totalRequests} />
|
||||
{intl.formatNumber(data.totalRequests)}
|
||||
</List.Item>
|
||||
{data.tz && (
|
||||
<List.Item title={intl.formatMessage(messages.timezone)}>
|
||||
{data.tz}
|
||||
<code>{data.tz}</code>
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
@@ -81,12 +135,12 @@ const SettingsAbout: React.FC = () => {
|
||||
</List.Item>
|
||||
<List.Item title="Discord">
|
||||
<a
|
||||
href="https://discord.gg/PkCWJSeCk7"
|
||||
href="https://discord.gg/overseerr"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-indigo-500 hover:underline"
|
||||
>
|
||||
{intl.formatMessage(messages.clickheretojoindiscord)}
|
||||
https://discord.gg/overseerr
|
||||
</a>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import { FormattedRelativeTime, defineMessages, useIntl } from 'react-intl';
|
||||
import Button from '../../Common/Button';
|
||||
import Table from '../../Common/Table';
|
||||
import Spinner from '../../../assets/spinner.svg';
|
||||
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline';
|
||||
import axios from 'axios';
|
||||
import React from 'react';
|
||||
import {
|
||||
defineMessages,
|
||||
FormattedRelativeTime,
|
||||
MessageDescriptor,
|
||||
useIntl,
|
||||
} from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Badge from '../../Common/Badge';
|
||||
import useSWR from 'swr';
|
||||
import { CacheItem } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
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 PageTitle from '../../Common/PageTitle';
|
||||
import Table from '../../Common/Table';
|
||||
|
||||
const messages = defineMessages({
|
||||
const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
|
||||
jobsandcache: 'Jobs & Cache',
|
||||
jobs: 'Jobs',
|
||||
jobsDescription:
|
||||
'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.',
|
||||
@@ -35,6 +44,13 @@ const messages = defineMessages({
|
||||
cacheksize: 'Key Size',
|
||||
cachevsize: 'Value Size',
|
||||
flushcache: 'Flush Cache',
|
||||
unknownJob: 'Unknown Job',
|
||||
'plex-recently-added-scan': 'Plex Recently Added Scan',
|
||||
'plex-full-scan': 'Plex Full Library Scan',
|
||||
'radarr-scan': 'Radarr Scan',
|
||||
'sonarr-scan': 'Sonarr Scan',
|
||||
'download-sync': 'Download Sync',
|
||||
'download-sync-reset': 'Download Sync Reset',
|
||||
});
|
||||
|
||||
interface Job {
|
||||
@@ -66,7 +82,7 @@ const SettingsJobs: React.FC = () => {
|
||||
await axios.post(`/api/v1/settings/jobs/${job.id}/run`);
|
||||
addToast(
|
||||
intl.formatMessage(messages.jobstarted, {
|
||||
jobname: job.name,
|
||||
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
|
||||
}),
|
||||
{
|
||||
appearance: 'success',
|
||||
@@ -78,10 +94,15 @@ const SettingsJobs: React.FC = () => {
|
||||
|
||||
const cancelJob = async (job: Job) => {
|
||||
await axios.post(`/api/v1/settings/jobs/${job.id}/cancel`);
|
||||
addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
addToast(
|
||||
intl.formatMessage(messages.jobcancelled, {
|
||||
jobname: intl.formatMessage(messages[job.id] ?? messages.unknownJob),
|
||||
}),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
revalidate();
|
||||
};
|
||||
|
||||
@@ -99,6 +120,12 @@ const SettingsJobs: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.jobsandcache),
|
||||
intl.formatMessage(globalMessages.settings),
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">{intl.formatMessage(messages.jobs)}</h3>
|
||||
<p className="description">
|
||||
@@ -120,8 +147,12 @@ const SettingsJobs: React.FC = () => {
|
||||
<tr key={`job-list-${job.id}`}>
|
||||
<Table.TD>
|
||||
<div className="flex items-center text-sm leading-5 text-white">
|
||||
{job.running && <Spinner className="w-5 h-5 mr-2" />}
|
||||
<span>{job.name}</span>
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
messages[job.id] ?? messages.unknownJob
|
||||
)}
|
||||
</span>
|
||||
{job.running && <Spinner className="w-5 h-5 ml-2" />}
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
@@ -143,17 +174,20 @@ const SettingsJobs: React.FC = () => {
|
||||
1000
|
||||
)}
|
||||
updateIntervalInSeconds={1}
|
||||
numeric="auto"
|
||||
/>
|
||||
</div>
|
||||
</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
{job.running ? (
|
||||
<Button buttonType="danger" onClick={() => cancelJob(job)}>
|
||||
{intl.formatMessage(messages.canceljob)}
|
||||
<StopIcon />
|
||||
<span>{intl.formatMessage(messages.canceljob)}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="primary" onClick={() => runJob(job)}>
|
||||
{intl.formatMessage(messages.runnow)}
|
||||
<PlayIcon className="w-5 h-5 mr-1" />
|
||||
<span>{intl.formatMessage(messages.runnow)}</span>
|
||||
</Button>
|
||||
)}
|
||||
</Table.TD>
|
||||
@@ -192,7 +226,8 @@ const SettingsJobs: React.FC = () => {
|
||||
<Table.TD>{formatBytes(cache.stats.vsize)}</Table.TD>
|
||||
<Table.TD alignText="right">
|
||||
<Button buttonType="danger" onClick={() => flushCache(cache)}>
|
||||
{intl.formatMessage(messages.flushcache)}
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(messages.flushcache)}</span>
|
||||
</Button>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import SettingsTabs, { SettingsRoute } from '../Common/SettingsTabs';
|
||||
|
||||
const messages = defineMessages({
|
||||
settings: 'Settings',
|
||||
menuGeneralSettings: 'General Settings',
|
||||
menuGeneralSettings: 'General',
|
||||
menuUsers: 'Users',
|
||||
menuPlexSettings: 'Plex',
|
||||
menuJellyfinSettings: 'Jellyfin',
|
||||
menuServices: 'Services',
|
||||
@@ -16,14 +16,7 @@ const messages = defineMessages({
|
||||
menuAbout: 'About',
|
||||
});
|
||||
|
||||
interface SettingsRoute {
|
||||
text: string;
|
||||
route: string;
|
||||
regex: RegExp;
|
||||
}
|
||||
|
||||
const SettingsLayout: React.FC = ({ children }) => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
|
||||
const settingsRoutes: SettingsRoute[] = [
|
||||
@@ -32,6 +25,11 @@ const SettingsLayout: React.FC = ({ children }) => {
|
||||
route: '/settings/main',
|
||||
regex: /^\/settings(\/main)?$/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuUsers),
|
||||
route: '/settings/users',
|
||||
regex: /^\/settings\/users/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.menuPlexSettings),
|
||||
route: '/settings/plex',
|
||||
@@ -69,78 +67,11 @@ const SettingsLayout: React.FC = ({ children }) => {
|
||||
},
|
||||
];
|
||||
|
||||
const activeLinkColor =
|
||||
'border-indigo-600 text-indigo-500 focus:outline-none focus:text-indigo-500 focus:border-indigo-500';
|
||||
|
||||
const inactiveLinkColor =
|
||||
'border-transparent text-gray-500 hover:text-gray-400 hover:border-gray-300 focus:outline-none focus:text-gray-4700 focus:border-gray-300';
|
||||
|
||||
const SettingsLink: React.FC<{
|
||||
route: string;
|
||||
regex: RegExp;
|
||||
isMobile?: boolean;
|
||||
}> = ({ children, route, regex, isMobile = false }) => {
|
||||
if (isMobile) {
|
||||
return <option value={route}>{children}</option>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={route}>
|
||||
<a
|
||||
className={`whitespace-nowrap ml-8 first:ml-0 py-4 px-1 border-b-2 border-transparent font-medium text-sm leading-5 ${
|
||||
router.pathname.match(regex) ? activeLinkColor : inactiveLinkColor
|
||||
}`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.settings)} />
|
||||
<PageTitle title={intl.formatMessage(globalMessages.settings)} />
|
||||
<div className="mt-6">
|
||||
<div className="sm:hidden">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
defaultValue={
|
||||
settingsRoutes.find(
|
||||
(route) => !!router.pathname.match(route.regex)
|
||||
)?.route
|
||||
}
|
||||
aria-label="Selected tab"
|
||||
>
|
||||
{settingsRoutes.map((route, index) => (
|
||||
<SettingsLink
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
isMobile
|
||||
key={`mobile-settings-link-${index}`}
|
||||
>
|
||||
{route.text}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden overflow-x-scroll overflow-y-hidden border-b border-gray-600 sm:block hide-scrollbar">
|
||||
<nav className="flex -mb-px">
|
||||
{settingsRoutes.map((route, index) => (
|
||||
<SettingsLink
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
key={`standard-settings-link-${index}`}
|
||||
>
|
||||
{route.text}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<SettingsTabs settingsRoutes={settingsRoutes} />
|
||||
</div>
|
||||
<div className="mt-10 text-white">{children}</div>
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,447 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardCopyIcon,
|
||||
DocumentSearchIcon,
|
||||
FilterIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
LogMessage,
|
||||
LogsResultsResponse,
|
||||
} from '../../../../server/interfaces/api/settingsInterfaces';
|
||||
import { useUpdateQueryParams } from '../../../hooks/useUpdateQueryParams';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Error from '../../../pages/_error';
|
||||
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';
|
||||
|
||||
// We will localize this file when the complete version is released.
|
||||
const messages = defineMessages({
|
||||
logs: 'Logs',
|
||||
logsDescription:
|
||||
'You can also view these logs directly via <code>stdout</code>, or in <code>{configDir}/logs/overseerr.log</code>.',
|
||||
time: 'Timestamp',
|
||||
level: 'Severity',
|
||||
label: 'Label',
|
||||
message: 'Message',
|
||||
filterDebug: 'Debug',
|
||||
filterInfo: 'Info',
|
||||
filterWarn: 'Warning',
|
||||
filterError: 'Error',
|
||||
showall: 'Show All Logs',
|
||||
pauseLogs: 'Pause',
|
||||
resumeLogs: 'Resume',
|
||||
copyToClipboard: 'Copy to Clipboard',
|
||||
logDetails: 'Log Details',
|
||||
extraData: 'Additional Data',
|
||||
copiedLogMessage: 'Copied log message to clipboard.',
|
||||
});
|
||||
|
||||
type Filter = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const SettingsLogs: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const [currentFilter, setCurrentFilter] = useState<Filter>('debug');
|
||||
const [currentPageSize, setCurrentPageSize] = useState(25);
|
||||
const [refreshInterval, setRefreshInterval] = useState(5000);
|
||||
const [activeLog, setActiveLog] = useState<LogMessage | null>(null);
|
||||
|
||||
const page = router.query.page ? Number(router.query.page) : 1;
|
||||
const pageIndex = page - 1;
|
||||
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
|
||||
|
||||
const toggleLogs = () => {
|
||||
setRefreshInterval(refreshInterval === 5000 ? 0 : 5000);
|
||||
};
|
||||
|
||||
const { data, error } = useSWR<LogsResultsResponse>(
|
||||
`/api/v1/settings/logs?take=${currentPageSize}&skip=${
|
||||
pageIndex * currentPageSize
|
||||
}&filter=${currentFilter}`,
|
||||
{
|
||||
refreshInterval: refreshInterval,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: appData } = useSWR('/api/v1/status/appdata');
|
||||
|
||||
useEffect(() => {
|
||||
const filterString = window.localStorage.getItem('logs-display-settings');
|
||||
|
||||
if (filterString) {
|
||||
const filterSettings = JSON.parse(filterString);
|
||||
|
||||
setCurrentFilter(filterSettings.currentFilter);
|
||||
setCurrentPageSize(filterSettings.currentPageSize);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
'logs-display-settings',
|
||||
JSON.stringify({
|
||||
currentFilter,
|
||||
currentPageSize,
|
||||
})
|
||||
);
|
||||
}, [currentFilter, currentPageSize]);
|
||||
|
||||
const copyLogString = (log: LogMessage): void => {
|
||||
copy(
|
||||
`${log.timestamp} [${log.level}]${log.label ? `[${log.label}]` : ''}: ${
|
||||
log.message
|
||||
}${log.data ? `${JSON.stringify(log.data)}` : ''}`
|
||||
);
|
||||
addToast(intl.formatMessage(messages.copiedLogMessage), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
const hasNextPage = data.pageInfo.pages > pageIndex + 1;
|
||||
const hasPrevPage = pageIndex > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm leading-loose text-gray-300">
|
||||
This page is still being built. For now, you can access your logs
|
||||
directly in <code>stdout</code> (container logs) or looking in{' '}
|
||||
<code>/app/config/logs/overseerr.log</code>.
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.logs),
|
||||
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"
|
||||
appear
|
||||
show={!!activeLog}
|
||||
>
|
||||
<Modal
|
||||
title={intl.formatMessage(messages.logDetails)}
|
||||
iconSvg={<DocumentSearchIcon />}
|
||||
onCancel={() => setActiveLog(null)}
|
||||
cancelText={intl.formatMessage(globalMessages.close)}
|
||||
onOk={() => (activeLog ? copyLogString(activeLog) : undefined)}
|
||||
okText={intl.formatMessage(messages.copyToClipboard)}
|
||||
okButtonType="primary"
|
||||
>
|
||||
{activeLog && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<div className="text-label">
|
||||
{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">
|
||||
{intl.formatDate(activeLog.timestamp, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="text-label">
|
||||
{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">
|
||||
<Badge
|
||||
badgeType={
|
||||
activeLog.level === 'error'
|
||||
? 'danger'
|
||||
: activeLog.level === 'warn'
|
||||
? 'warning'
|
||||
: activeLog.level === 'info'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{activeLog.level.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="text-label">
|
||||
{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">
|
||||
{activeLog.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="text-label">
|
||||
{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">
|
||||
{activeLog.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activeLog.data && (
|
||||
<div className="form-row">
|
||||
<div className="text-label">
|
||||
{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">
|
||||
{JSON.stringify(activeLog.data, null, ' ')}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
<div className="mb-2">
|
||||
<h3 className="heading">{intl.formatMessage(messages.logs)}</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.logsDescription, {
|
||||
code: function code(msg) {
|
||||
return <code className="bg-opacity-50">{msg}</code>;
|
||||
},
|
||||
configDir: 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">
|
||||
<Button
|
||||
className="flex-grow w-full mr-2"
|
||||
buttonType={refreshInterval ? 'default' : 'primary'}
|
||||
onClick={() => toggleLogs()}
|
||||
>
|
||||
{refreshInterval ? <PauseIcon /> : <PlayIcon />}
|
||||
<span>
|
||||
{intl.formatMessage(
|
||||
refreshInterval ? messages.pauseLogs : messages.resumeLogs
|
||||
)}
|
||||
</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" />
|
||||
</span>
|
||||
<select
|
||||
id="filter"
|
||||
name="filter"
|
||||
onChange={(e) => {
|
||||
setCurrentFilter(e.target.value as Filter);
|
||||
router.push(router.pathname);
|
||||
}}
|
||||
value={currentFilter}
|
||||
className="rounded-r-only"
|
||||
>
|
||||
<option value="debug">
|
||||
{intl.formatMessage(messages.filterDebug)}
|
||||
</option>
|
||||
<option value="info">
|
||||
{intl.formatMessage(messages.filterInfo)}
|
||||
</option>
|
||||
<option value="warn">
|
||||
{intl.formatMessage(messages.filterWarn)}
|
||||
</option>
|
||||
<option value="error">
|
||||
{intl.formatMessage(messages.filterError)}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<Table.TH>{intl.formatMessage(messages.time)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.level)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.label)}</Table.TH>
|
||||
<Table.TH>{intl.formatMessage(messages.message)}</Table.TH>
|
||||
<Table.TH></Table.TH>
|
||||
</tr>
|
||||
</thead>
|
||||
<Table.TBody>
|
||||
{data.results.map((row: LogMessage, index: number) => {
|
||||
return (
|
||||
<tr key={`log-list-${index}`}>
|
||||
<Table.TD className="text-gray-300">
|
||||
{intl.formatDate(row.timestamp, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
})}
|
||||
</Table.TD>
|
||||
<Table.TD className="text-gray-300">
|
||||
<Badge
|
||||
badgeType={
|
||||
row.level === 'error'
|
||||
? 'danger'
|
||||
: row.level === 'warn'
|
||||
? 'warning'
|
||||
: row.level === 'info'
|
||||
? 'success'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{row.level.toUpperCase()}
|
||||
</Badge>
|
||||
</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">
|
||||
{row.data && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => setActiveLog(row)}
|
||||
className="m-1"
|
||||
>
|
||||
<DocumentSearchIcon className="icon-md" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
onClick={() => copyLogString(row)}
|
||||
className="m-1"
|
||||
>
|
||||
<ClipboardCopyIcon className="icon-md" />
|
||||
</Button>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{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">
|
||||
<span className="text-base">
|
||||
{intl.formatMessage(globalMessages.noresults)}
|
||||
</span>
|
||||
{currentFilter !== 'debug' && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
buttonType="primary"
|
||||
onClick={() => setCurrentFilter('debug')}
|
||||
>
|
||||
{intl.formatMessage(messages.showall)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
)}
|
||||
<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"
|
||||
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 md:justify-center">
|
||||
<span className="items-center -mt-3 text-sm sm:-ml-4 md:ml-0 sm:mt-0">
|
||||
{intl.formatMessage(globalMessages.resultsperpage, {
|
||||
pageSize: (
|
||||
<select
|
||||
id="pageSize"
|
||||
name="pageSize"
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(Number(e.target.value));
|
||||
router
|
||||
.push(router.pathname)
|
||||
.then(() => window.scrollTo(0, 0));
|
||||
}}
|
||||
value={currentPageSize}
|
||||
className="inline short"
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center flex-auto space-x-2 sm:justify-end sm:flex-1">
|
||||
<Button
|
||||
disabled={!hasPrevPage}
|
||||
onClick={() =>
|
||||
updateQueryParams('page', (page - 1).toString())
|
||||
}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span>{intl.formatMessage(globalMessages.previous)}</span>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage}
|
||||
onClick={() =>
|
||||
updateQueryParams('page', (page + 1).toString())
|
||||
}
|
||||
>
|
||||
<span>{intl.formatMessage(globalMessages.next)}</span>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,66 +1,77 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import type { MainSettings, Language } from '../../../server/lib/settings';
|
||||
import CopyButton from './CopyButton';
|
||||
import { Form, Formik, Field } from 'formik';
|
||||
import { SaveIcon } from '@heroicons/react/outline';
|
||||
import { RefreshIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Button from '../Common/Button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Badge from '../Common/Badge';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import PermissionEdit from '../PermissionEdit';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import { UserSettingsGeneralResponse } from '../../../server/interfaces/api/userSettingsInterfaces';
|
||||
import type { MainSettings } from '../../../server/lib/settings';
|
||||
import {
|
||||
availableLanguages,
|
||||
AvailableLocale,
|
||||
} from '../../context/LanguageContext';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
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 LanguageSelector from '../LanguageSelector';
|
||||
import RegionSelector from '../RegionSelector';
|
||||
import CopyButton from './CopyButton';
|
||||
|
||||
const messages = defineMessages({
|
||||
general: 'General',
|
||||
generalsettings: 'General Settings',
|
||||
generalsettingsDescription:
|
||||
'Configure global and default settings for Overseerr.',
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
apikey: 'API Key',
|
||||
applicationTitle: 'Application Title',
|
||||
applicationurl: 'Application URL',
|
||||
region: 'Discover Region',
|
||||
regionTip:
|
||||
'Filter content by region (only applies to the "Popular" and "Upcoming" categories)',
|
||||
regionTip: 'Filter content by regional availability',
|
||||
originallanguage: 'Discover Language',
|
||||
originallanguageTip:
|
||||
'Filter content by original language (only applies to the "Popular" and "Upcoming" categories)',
|
||||
toastApiKeySuccess: 'New API key generated!',
|
||||
originallanguageTip: 'Filter content by original language',
|
||||
toastApiKeySuccess: 'New API key generated successfully!',
|
||||
toastApiKeyFailure: 'Something went wrong while generating a new API key.',
|
||||
toastSettingsSuccess: 'Settings successfully saved!',
|
||||
toastSettingsSuccess: 'Settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
defaultPermissions: 'Default User Permissions',
|
||||
hideAvailable: 'Hide Available Media',
|
||||
csrfProtection: 'Enable CSRF Protection',
|
||||
csrfProtectionTip:
|
||||
'Sets external API access to read-only (requires HTTPS and Overseerr 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 unless you understand what you are doing!',
|
||||
'Do NOT enable this setting unless you understand what you are doing!',
|
||||
cacheImages: 'Enable Image Caching',
|
||||
cacheImagesTip:
|
||||
'Optimize and store all images locally (consumes a significant amount of disk space)',
|
||||
trustProxy: 'Enable Proxy Support',
|
||||
trustProxyTip:
|
||||
'Allows Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)',
|
||||
localLogin: 'Enable Local User Sign-In',
|
||||
'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',
|
||||
originalLanguageDefault: 'All Languages',
|
||||
partialRequestsEnabled: 'Allow Partial Series Requests',
|
||||
locale: 'Display Language',
|
||||
});
|
||||
|
||||
const SettingsMain: React.FC = () => {
|
||||
const { addToast } = useToasts();
|
||||
const { hasPermission: userHasPermission } = useUser();
|
||||
const { user: currentUser, hasPermission: userHasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const { setLocale } = useLocale();
|
||||
const { data, error, revalidate } = useSWR<MainSettings>(
|
||||
'/api/v1/settings/main'
|
||||
);
|
||||
const { data: languages, error: languagesError } = useSWR<Language[]>(
|
||||
'/api/v1/languages'
|
||||
const { data: userData } = useSWR<UserSettingsGeneralResponse>(
|
||||
currentUser ? `/api/v1/user/${currentUser.id}/settings/main` : null
|
||||
);
|
||||
|
||||
const MainSettingsSchema = Yup.object().shape({
|
||||
applicationTitle: Yup.string().required(
|
||||
intl.formatMessage(messages.validationApplicationTitle)
|
||||
@@ -96,12 +107,18 @@ const SettingsMain: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!data && !error && !languages && !languagesError) {
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.general),
|
||||
intl.formatMessage(globalMessages.settings),
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.generalsettings)}
|
||||
@@ -116,11 +133,11 @@ const SettingsMain: React.FC = () => {
|
||||
applicationTitle: data?.applicationTitle,
|
||||
applicationUrl: data?.applicationUrl,
|
||||
csrfProtection: data?.csrfProtection,
|
||||
defaultPermissions: data?.defaultPermissions ?? 0,
|
||||
hideAvailable: data?.hideAvailable,
|
||||
localLogin: data?.localLogin,
|
||||
locale: data?.locale ?? 'en',
|
||||
region: data?.region,
|
||||
originalLanguage: data?.originalLanguage,
|
||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||
trustProxy: data?.trustProxy,
|
||||
}}
|
||||
enableReinitialize
|
||||
@@ -131,13 +148,22 @@ const SettingsMain: React.FC = () => {
|
||||
applicationTitle: values.applicationTitle,
|
||||
applicationUrl: values.applicationUrl,
|
||||
csrfProtection: values.csrfProtection,
|
||||
defaultPermissions: values.defaultPermissions,
|
||||
hideAvailable: values.hideAvailable,
|
||||
localLogin: values.localLogin,
|
||||
locale: values.locale,
|
||||
region: values.region,
|
||||
originalLanguage: values.originalLanguage,
|
||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||
trustProxy: values.trustProxy,
|
||||
});
|
||||
mutate('/api/v1/settings/public');
|
||||
|
||||
if (setLocale) {
|
||||
setLocale(
|
||||
(userData?.locale
|
||||
? userData.locale
|
||||
: values.locale) as AvailableLocale
|
||||
);
|
||||
}
|
||||
|
||||
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||
autoDismiss: true,
|
||||
@@ -153,7 +179,14 @@ const SettingsMain: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, values, setFieldValue }) => {
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
{userHasPermission(Permission.ADMIN) && (
|
||||
@@ -162,8 +195,8 @@ const SettingsMain: React.FC = () => {
|
||||
{intl.formatMessage(messages.apikey)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<input
|
||||
<div className="form-input-field">
|
||||
<SensitiveInput
|
||||
type="text"
|
||||
id="apiKey"
|
||||
className="rounded-l-only"
|
||||
@@ -179,20 +212,9 @@ const SettingsMain: React.FC = () => {
|
||||
e.preventDefault();
|
||||
regenerate();
|
||||
}}
|
||||
className="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-gray-500 rounded-r-md hover:bg-indigo-500 focus:outline-none focus:ring-blue focus:border-blue-300 active:bg-gray-100 active:text-gray-700"
|
||||
className="input-action"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,12 +225,11 @@ const SettingsMain: React.FC = () => {
|
||||
{intl.formatMessage(messages.applicationTitle)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="applicationTitle"
|
||||
name="applicationTitle"
|
||||
type="text"
|
||||
placeholder="Overseerr"
|
||||
/>
|
||||
</div>
|
||||
{errors.applicationTitle && touched.applicationTitle && (
|
||||
@@ -221,12 +242,12 @@ const SettingsMain: React.FC = () => {
|
||||
{intl.formatMessage(messages.applicationurl)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<div className="form-input-field">
|
||||
<Field
|
||||
id="applicationUrl"
|
||||
name="applicationUrl"
|
||||
type="text"
|
||||
placeholder="https://os.example.com"
|
||||
inputMode="url"
|
||||
/>
|
||||
</div>
|
||||
{errors.applicationUrl && touched.applicationUrl && (
|
||||
@@ -278,6 +299,28 @@ const SettingsMain: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="locale" className="text-label">
|
||||
{intl.formatMessage(messages.locale)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field as="select" id="locale" name="locale">
|
||||
{(Object.keys(
|
||||
availableLanguages
|
||||
) as (keyof typeof availableLanguages)[]).map((key) => (
|
||||
<option
|
||||
key={key}
|
||||
value={availableLanguages[key].code}
|
||||
lang={availableLanguages[key].code}
|
||||
>
|
||||
{availableLanguages[key].display}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="region" className="text-label">
|
||||
<span>{intl.formatMessage(messages.region)}</span>
|
||||
@@ -286,11 +329,13 @@ const SettingsMain: React.FC = () => {
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<RegionSelector
|
||||
value={values.region ?? ''}
|
||||
name="region"
|
||||
onChange={setFieldValue}
|
||||
/>
|
||||
<div className="form-input-field">
|
||||
<RegionSelector
|
||||
value={values.region ?? ''}
|
||||
name="region"
|
||||
onChange={setFieldValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
@@ -301,27 +346,11 @@ const SettingsMain: React.FC = () => {
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
as="select"
|
||||
id="originalLanguage"
|
||||
name="originalLanguage"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.originalLanguageDefault)}
|
||||
</option>
|
||||
{languages?.map((language) => (
|
||||
<option
|
||||
key={`language-key-${language.iso_639_1}`}
|
||||
value={language.iso_639_1}
|
||||
>
|
||||
{intl.formatDisplayName(language.iso_639_1, {
|
||||
type: 'language',
|
||||
fallback: 'none',
|
||||
}) ?? language.english_name}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
<div className="form-input-field">
|
||||
<LanguageSelector
|
||||
setFieldValue={setFieldValue}
|
||||
value={values.originalLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,52 +375,42 @@ const SettingsMain: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="localLogin" className="checkbox-label">
|
||||
<span>{intl.formatMessage(messages.localLogin)}</span>
|
||||
<label
|
||||
htmlFor="partialRequestsEnabled"
|
||||
className="checkbox-label"
|
||||
>
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(messages.partialRequestsEnabled)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="localLogin"
|
||||
name="localLogin"
|
||||
id="partialRequestsEnabled"
|
||||
name="partialRequestsEnabled"
|
||||
onChange={() => {
|
||||
setFieldValue('localLogin', !values.localLogin);
|
||||
setFieldValue(
|
||||
'partialRequestsEnabled',
|
||||
!values.partialRequestsEnabled
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
className="form-group"
|
||||
>
|
||||
<div className="form-row">
|
||||
<span id="group-label" className="group-label">
|
||||
{intl.formatMessage(messages.defaultPermissions)}
|
||||
</span>
|
||||
<div className="form-input">
|
||||
<div className="max-w-lg">
|
||||
<PermissionEdit
|
||||
currentPermission={values.defaultPermissions}
|
||||
onUpdate={(newPermissions) =>
|
||||
setFieldValue('defaultPermissions', newPermissions)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
<SaveIcon />
|
||||
<span>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,76 +1,52 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
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 SlackLogo from '../../assets/extlogos/slack.svg';
|
||||
import TelegramLogo from '../../assets/extlogos/telegram.svg';
|
||||
import LunaSeaLogo from '../../assets/extlogos/lunasea.svg';
|
||||
import PushbulletLogo from '../../assets/extlogos/pushbullet.svg';
|
||||
import PushoverLogo from '../../assets/extlogos/pushover.svg';
|
||||
import Bolt from '../../assets/bolt.svg';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useSWR from 'swr';
|
||||
import Error from '../../pages/_error';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import axios from 'axios';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import Button from '../Common/Button';
|
||||
import SlackLogo from '../../assets/extlogos/slack.svg';
|
||||
import TelegramLogo from '../../assets/extlogos/telegram.svg';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import SettingsTabs, { SettingsRoute } from '../Common/SettingsTabs';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving…',
|
||||
notifications: 'Notifications',
|
||||
notificationsettings: 'Notification Settings',
|
||||
notificationsettingsDescription:
|
||||
'Configure global notification settings. The options below will apply to all notification agents.',
|
||||
notificationAgentsSettings: 'Notification Agents',
|
||||
notificationAgentSettingsDescription:
|
||||
'Choose the types of notifications to send, and which notification agents to use.',
|
||||
notificationsettingssaved: 'Notification settings saved successfully!',
|
||||
notificationsettingsfailed: 'Notification settings failed to save.',
|
||||
enablenotifications: 'Enable Notifications',
|
||||
autoapprovedrequests: 'Enable Notifications for Automatic Approvals',
|
||||
'Configure and enable notification agents.',
|
||||
email: 'Email',
|
||||
webhook: 'Webhook',
|
||||
webpush: 'Web Push',
|
||||
});
|
||||
|
||||
interface SettingsRoute {
|
||||
text: string;
|
||||
content: React.ReactNode;
|
||||
route: string;
|
||||
regex: RegExp;
|
||||
}
|
||||
|
||||
const SettingsNotifications: React.FC = ({ children }) => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR('/api/v1/settings/notifications');
|
||||
|
||||
const settingsRoutes: SettingsRoute[] = [
|
||||
{
|
||||
text: intl.formatMessage(messages.email),
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<svg
|
||||
className="h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||
/>
|
||||
</svg>
|
||||
<MailIcon className="h-4 mr-2" />
|
||||
{intl.formatMessage(messages.email)}
|
||||
</span>
|
||||
),
|
||||
route: '/settings/notifications/email',
|
||||
regex: /^\/settings\/notifications\/email/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.webpush),
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<CloudIcon className="h-4 mr-2" />
|
||||
{intl.formatMessage(messages.webpush)}
|
||||
</span>
|
||||
),
|
||||
route: '/settings/notifications/webpush',
|
||||
regex: /^\/settings\/notifications\/webpush/,
|
||||
},
|
||||
{
|
||||
text: 'Discord',
|
||||
content: (
|
||||
@@ -82,6 +58,17 @@ const SettingsNotifications: React.FC = ({ children }) => {
|
||||
route: '/settings/notifications/discord',
|
||||
regex: /^\/settings\/notifications\/discord/,
|
||||
},
|
||||
{
|
||||
text: 'LunaSea',
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<LunaSeaLogo className="h-4 mr-2" />
|
||||
LunaSea
|
||||
</span>
|
||||
),
|
||||
route: '/settings/notifications/lunasea',
|
||||
regex: /^\/settings\/notifications\/lunasea/,
|
||||
},
|
||||
{
|
||||
text: 'Pushbullet',
|
||||
content: (
|
||||
@@ -130,7 +117,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
|
||||
text: intl.formatMessage(messages.webhook),
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<Bolt className="h-4 mr-2" />
|
||||
<LightningBoltIcon className="h-4 mr-2" />
|
||||
{intl.formatMessage(messages.webhook)}
|
||||
</span>
|
||||
),
|
||||
@@ -139,193 +126,23 @@ const SettingsNotifications: React.FC = ({ children }) => {
|
||||
},
|
||||
];
|
||||
|
||||
const activeLinkColor = 'bg-indigo-700';
|
||||
const inactiveLinkColor = 'bg-gray-800';
|
||||
|
||||
const SettingsLink: React.FC<{
|
||||
route: string;
|
||||
regex: RegExp;
|
||||
isMobile?: boolean;
|
||||
}> = ({ children, route, regex, isMobile = false }) => {
|
||||
if (isMobile) {
|
||||
return <option value={route}>{children}</option>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={route}>
|
||||
<a
|
||||
className={`whitespace-nowrap ml-8 first:ml-0 px-3 py-2 font-medium text-sm rounded-md ${
|
||||
router.pathname.match(regex) ? activeLinkColor : inactiveLinkColor
|
||||
}`}
|
||||
aria-current="page"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={500} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title={[
|
||||
intl.formatMessage(messages.notifications),
|
||||
intl.formatMessage(globalMessages.settings),
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.notificationsettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.notificationsettingsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section">
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
autoapprovalEnabled: data.autoapprovalEnabled,
|
||||
}}
|
||||
enableReinitialize
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications', {
|
||||
enabled: values.enabled,
|
||||
autoapprovalEnabled: values.autoapprovalEnabled,
|
||||
});
|
||||
addToast(intl.formatMessage(messages.notificationsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.notificationsettingsfailed),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values, setFieldValue }) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="checkbox-label">
|
||||
<span>
|
||||
{intl.formatMessage(messages.enablenotifications)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
onChange={() => {
|
||||
setFieldValue('enabled', !values.enabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="name" className="checkbox-label">
|
||||
<span>
|
||||
{intl.formatMessage(messages.autoapprovedrequests)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="autoapprovalEnabled"
|
||||
name="autoapprovalEnabled"
|
||||
onChange={() => {
|
||||
setFieldValue(
|
||||
'autoapprovalEnabled',
|
||||
!values.autoapprovalEnabled
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
<div className="mt-10 mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.notificationAgentsSettings)}
|
||||
</h3>
|
||||
<p className="description">
|
||||
{intl.formatMessage(messages.notificationAgentSettingsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="tabs" className="sr-only">
|
||||
Select a tab
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
defaultValue={
|
||||
settingsRoutes.find(
|
||||
(route) => !!router.pathname.match(route.regex)
|
||||
)?.route
|
||||
}
|
||||
aria-label="Selected tab"
|
||||
>
|
||||
{settingsRoutes.map((route, index) => (
|
||||
<SettingsLink
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
isMobile
|
||||
key={`mobile-settings-link-${index}`}
|
||||
>
|
||||
{route.text}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden overflow-x-scroll overflow-y-hidden sm:block hide-scrollbar">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{settingsRoutes.map((route, index) => (
|
||||
<SettingsLink
|
||||
route={route.route}
|
||||
regex={route.regex}
|
||||
key={`standard-settings-link-${index}`}
|
||||
>
|
||||
{route.content}
|
||||
</SettingsLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsTabs tabType="button" settingsRoutes={settingsRoutes} />
|
||||
<div className="section">{children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user