Merge remote-tracking branch 'overseerr/develop' into develop

This commit is contained in:
notfakie
2022-09-01 18:11:15 +12:00
473 changed files with 15548 additions and 8433 deletions

1
src/assets/infinity.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.1.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M494.9 96.01c-38.78 0-75.22 15.09-102.6 42.5L320 210.8L247.8 138.5c-27.41-27.41-63.84-42.5-102.6-42.5C65.11 96.01 0 161.1 0 241.1v29.75c0 80.03 65.11 145.1 145.1 145.1c38.78 0 75.22-15.09 102.6-42.5L320 301.3l72.23 72.25c27.41 27.41 63.84 42.5 102.6 42.5C574.9 416 640 350.9 640 270.9v-29.75C640 161.1 574.9 96.01 494.9 96.01zM202.5 328.3c-15.31 15.31-35.69 23.75-57.38 23.75C100.4 352 64 315.6 64 270.9v-29.75c0-44.72 36.41-81.13 81.14-81.13c21.69 0 42.06 8.438 57.38 23.75l72.23 72.25L202.5 328.3zM576 270.9c0 44.72-36.41 81.13-81.14 81.13c-21.69 0-42.06-8.438-57.38-23.75l-72.23-72.25l72.23-72.25c15.31-15.31 35.69-23.75 57.38-23.75C539.6 160 576 196.4 576 241.1V270.9z" fill="currentColor" /></svg>

After

Width:  |  Height:  |  Size: 941 B

View File

@@ -0,0 +1,62 @@
import Badge from '@app/components/Common/Badge';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
const messages = defineMessages({
airedrelative: 'Aired {relativeTime}',
airsrelative: 'Airing {relativeTime}',
});
type AirDateBadgeProps = {
airDate: string;
};
const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
const WEEK = 1000 * 60 * 60 * 24 * 8;
const intl = useIntl();
const dAirDate = new Date(airDate);
const nowDate = new Date();
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
const compareWeek = new Date(
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
);
let showRelative = false;
if (
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
) {
showRelative = true;
}
return (
<div className="flex items-center space-x-2">
<Badge badgeType="light">
{intl.formatDate(dAirDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Badge>
{showRelative && (
<Badge badgeType="light">
{intl.formatMessage(
alreadyAired ? messages.airedrelative : messages.airsrelative,
{
relativeTime: (
<FormattedRelativeTime
value={(dAirDate.getTime() - Date.now()) / 1000}
numeric="auto"
updateIntervalInSeconds={1}
/>
),
}
)}
</Badge>
)}
</div>
);
};
export default AirDateBadge;

View File

@@ -1,14 +1,13 @@
import React from 'react';
import Alert from '@app/components/Common/Alert';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import Alert from '../Common/Alert';
const messages = defineMessages({
dockerVolumeMissingDescription:
'The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.',
});
const AppDataWarning: React.FC = () => {
const AppDataWarning = () => {
const intl = useIntl();
const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>(
'/api/v1/status/appdata'
@@ -27,9 +26,9 @@ const AppDataWarning: React.FC = () => {
{!data.appData && (
<Alert
title={intl.formatMessage(messages.dockerVolumeMissingDescription, {
code: function code(msg) {
return <code className="bg-opacity-50">{msg}</code>;
},
code: (msg: React.ReactNode) => (
<code className="bg-opacity-50">{msg}</code>
),
appDataPath: data.appDataPath,
})}
/>

View File

@@ -1,24 +1,24 @@
import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import RequestModal from '@app/components/RequestModal';
import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge';
import TitleCard from '@app/components/TitleCard';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { DownloadIcon } from '@heroicons/react/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
import { uniq } from 'lodash';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import type { Collection } from '../../../server/models/Collection';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import RequestModal from '../RequestModal';
import Slider from '../Slider';
import StatusBadge from '../StatusBadge';
import TitleCard from '../TitleCard';
const messages = defineMessages({
overview: 'Overview',
@@ -31,9 +31,7 @@ interface CollectionDetailsProps {
collection?: Collection;
}
const CollectionDetails: React.FC<CollectionDetailsProps> = ({
collection,
}) => {
const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
const intl = useIntl();
const router = useRouter();
const settings = useSettings();

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import type * as React from 'react';
import { useState } from 'react';
import AnimateHeight from 'react-animate-height';
export interface AccordionProps {
children: (args: AccordionChildProps) => React.ReactElement<any, any> | null;
children: (args: AccordionChildProps) => JSX.Element;
/** If true, only one accordion item can be open at any time */
single?: boolean;
/** If true, at least one accordion item will always be open */
@@ -13,22 +13,27 @@ export interface AccordionProps {
export interface AccordionChildProps {
openIndexes: number[];
handleClick(index: number): void;
AccordionContent: any;
AccordionContent: typeof AccordionContent;
}
export const AccordionContent: React.FC<{ isOpen: boolean }> = ({
type AccordionContentProps = {
isOpen: boolean;
children: React.ReactNode;
};
export const AccordionContent = ({
isOpen,
children,
}) => {
}: AccordionContentProps) => {
return <AnimateHeight height={isOpen ? 'auto' : 0}>{children}</AnimateHeight>;
};
const Accordion: React.FC<AccordionProps> = ({
const Accordion = ({
single,
atLeastOne,
initialOpenIndexes,
children,
}) => {
}: AccordionProps) => {
const initialState = initialOpenIndexes || (atLeastOne && [0]) || [];
const [openIndexes, setOpenIndexes] = useState<number[]>(initialState);

View File

@@ -3,16 +3,17 @@ import {
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/solid';
import React from 'react';
interface AlertProps {
title?: React.ReactNode;
type?: 'warning' | 'info' | 'error';
children?: React.ReactNode;
}
const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
const Alert = ({ title, children, type }: AlertProps) => {
let design = {
bgColor: 'bg-yellow-600',
bgColor:
'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20',
titleColor: 'text-yellow-100',
textColor: 'text-yellow-300',
svg: <ExclamationIcon className="h-5 w-5" />,
@@ -21,9 +22,10 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
switch (type) {
case 'info':
design = {
bgColor: 'bg-indigo-600',
titleColor: 'text-indigo-100',
textColor: 'text-indigo-300',
bgColor:
'border border-indigo-500 backdrop-blur bg-indigo-400 bg-opacity-20',
titleColor: 'text-gray-100',
textColor: 'text-gray-300',
svg: <InformationCircleIcon className="h-5 w-5" />,
};
break;

View File

@@ -2,17 +2,23 @@ import Link from 'next/link';
import React from 'react';
interface BadgeProps {
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
badgeType?:
| 'default'
| 'primary'
| 'danger'
| 'warning'
| 'success'
| 'dark'
| 'light';
className?: string;
href?: string;
children: React.ReactNode;
}
const Badge: React.FC<BadgeProps> = ({
badgeType = 'default',
className,
href,
children,
}) => {
const Badge = (
{ badgeType = 'default', className, href, children }: BadgeProps,
ref?: React.Ref<HTMLElement>
) => {
const badgeStyle = [
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap',
];
@@ -25,27 +31,47 @@ const Badge: React.FC<BadgeProps> = ({
switch (badgeType) {
case 'danger':
badgeStyle.push('bg-red-600 !text-red-100');
badgeStyle.push(
'bg-red-600 bg-opacity-80 border-red-500 border !text-red-100'
);
if (href) {
badgeStyle.push('hover:bg-red-500');
badgeStyle.push('hover:bg-red-500 bg-opacity-100');
}
break;
case 'warning':
badgeStyle.push('bg-yellow-500 !text-yellow-100');
badgeStyle.push(
'bg-yellow-500 bg-opacity-80 border-yellow-500 border !text-yellow-100'
);
if (href) {
badgeStyle.push('hover:bg-yellow-400');
badgeStyle.push('hover:bg-yellow-500 hover:bg-opacity-100');
}
break;
case 'success':
badgeStyle.push('bg-green-500 !text-green-100');
badgeStyle.push(
'bg-green-500 bg-opacity-80 border border-green-500 !text-green-100'
);
if (href) {
badgeStyle.push('hover:bg-green-400');
badgeStyle.push('hover:bg-green-500 hover:bg-opacity-100');
}
break;
case 'dark':
badgeStyle.push('bg-gray-900 !text-gray-400');
if (href) {
badgeStyle.push('hover:bg-gray-800');
}
break;
case 'light':
badgeStyle.push('bg-gray-700 !text-gray-300');
if (href) {
badgeStyle.push('hover:bg-gray-600');
}
break;
default:
badgeStyle.push('bg-indigo-500 !text-indigo-100');
badgeStyle.push(
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
);
if (href) {
badgeStyle.push('hover:bg-indigo-400');
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
}
}
@@ -60,6 +86,7 @@ const Badge: React.FC<BadgeProps> = ({
target="_blank"
rel="noopener noreferrer"
className={badgeStyle.join(' ')}
ref={ref as React.Ref<HTMLAnchorElement>}
>
{children}
</a>
@@ -67,12 +94,24 @@ const Badge: React.FC<BadgeProps> = ({
} else if (href) {
return (
<Link href={href}>
<a className={badgeStyle.join(' ')}>{children}</a>
<a
className={badgeStyle.join(' ')}
ref={ref as React.Ref<HTMLAnchorElement>}
>
{children}
</a>
</Link>
);
} else {
return <span className={badgeStyle.join(' ')}>{children}</span>;
return (
<span
className={badgeStyle.join(' ')}
ref={ref as React.Ref<HTMLSpanElement>}
>
{children}
</span>
);
}
};
export default Badge;
export default React.forwardRef(Badge) as typeof Badge;

View File

@@ -1,4 +1,5 @@
import React, { ForwardedRef } from 'react';
import type { ForwardedRef } from 'react';
import React from 'react';
export type ButtonType =
| 'default'
@@ -50,22 +51,22 @@ function Button<P extends ElementTypes = 'button'>(
switch (buttonType) {
case 'primary':
buttonStyle.push(
'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'
'text-white border border-indigo-500 bg-indigo-600 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-opacity-100 active:border-indigo-700'
);
break;
case 'danger':
buttonStyle.push(
'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'
'text-white bg-red-600 bg-opacity-80 border-red-500 hover:bg-opacity-100 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 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'
'text-white border border-yellow-500 backdrop-blur bg-yellow-500 bg-opacity-80 hover:bg-opacity-100 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-opacity-100 active:border-yellow-700'
);
break;
case 'success':
buttonStyle.push(
'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'
'text-white bg-green-500 bg-opacity-80 border-green-500 hover:bg-opacity-100 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-opacity-100 active:border-green-700'
);
break;
case 'ghost':
@@ -75,7 +76,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
default:
buttonStyle.push(
'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'
'text-gray-200 bg-gray-800 bg-opacity-80 border-gray-600 hover:text-white hover:bg-gray-700 hover:border-gray-600 group-hover:text-white group-hover:bg-gray-700 group-hover:border-gray-600 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-700 active:border-gray-600'
);
}

View File

@@ -1,34 +1,29 @@
import useClickOutside from '@app/hooks/useClickOutside';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import React, {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ReactNode,
useRef,
useState,
} from 'react';
import useClickOutside from '../../../hooks/useClickOutside';
import { withProperties } from '../../../utils/typeHelpers';
import Transition from '../../Transition';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import { Fragment, useRef, useState } from 'react';
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
buttonType?: 'primary' | 'ghost';
}
const DropdownItem: React.FC<DropdownItemProps> = ({
const DropdownItem = ({
children,
buttonType = 'primary',
...props
}) => {
}: DropdownItemProps) => {
let styleClass = 'button-md text-white';
switch (buttonType) {
case 'ghost':
styleClass +=
' bg-gray-700 hover:bg-gray-600 focus:border-gray-500 focus:text-white';
' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
break;
default:
styleClass +=
' bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
}
return (
<a
@@ -42,19 +37,19 @@ const DropdownItem: React.FC<DropdownItemProps> = ({
interface ButtonWithDropdownProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
text: ReactNode;
dropdownIcon?: ReactNode;
text: React.ReactNode;
dropdownIcon?: React.ReactNode;
buttonType?: 'primary' | 'ghost';
}
const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
const ButtonWithDropdown = ({
text,
children,
dropdownIcon,
className,
buttonType = 'primary',
...props
}) => {
}: ButtonWithDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useClickOutside(buttonRef, () => setIsOpen(false));
@@ -70,14 +65,15 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
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';
styleClasses.dropdownClasses +=
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
break;
default:
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';
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 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';
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
}
return (
@@ -103,6 +99,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
</button>
<Transition
as={Fragment}
show={isOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"

View File

@@ -1,6 +1,6 @@
import Image, { ImageProps } from 'next/image';
import React from 'react';
import useSettings from '../../../hooks/useSettings';
import useSettings from '@app/hooks/useSettings';
import type { ImageProps } from 'next/image';
import Image from 'next/image';
/**
* The CachedImage component should be used wherever
@@ -9,7 +9,7 @@ import useSettings from '../../../hooks/useSettings';
* 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 CachedImage = (props: ImageProps) => {
const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;

View File

@@ -1,19 +1,20 @@
import React, { useRef, useState } from 'react';
import useClickOutside from '../../../hooks/useClickOutside';
import Button from '../Button';
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
import { useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
confirmText: React.ReactNode;
className?: string;
children: React.ReactNode;
}
const ConfirmButton: React.FC<ConfirmButtonProps> = ({
const ConfirmButton = ({
onClick,
children,
confirmText,
className,
}) => {
}: ConfirmButtonProps) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);

View File

@@ -1,22 +1,18 @@
import React from 'react';
interface HeaderProps {
extraMargin?: number;
subtext?: React.ReactNode;
children: React.ReactNode;
}
const Header: React.FC<HeaderProps> = ({
children,
extraMargin = 0,
subtext,
}) => {
const Header = ({ children, extraMargin = 0, subtext }: HeaderProps) => {
return (
<div className="mt-8 md:flex md:items-center md:justify-between">
<div className={`min-w-0 flex-1 mx-${extraMargin}`}>
<h2 className="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0">
<span className="bg-gradient-to-br from-indigo-400 to-purple-400 bg-clip-text text-transparent">
{children}
</span>
<h2
className="mb-4 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-4xl sm:leading-9 md:mb-0"
data-testid="page-header"
>
<span className="text-overseerr">{children}</span>
</h2>
{subtext && <div className="mt-2 text-gray-400">{subtext}</div>}
</div>

View File

@@ -1,10 +1,6 @@
import React, {
ForwardRefRenderFunction,
HTMLAttributes,
useEffect,
useState,
} from 'react';
import CachedImage from '../CachedImage';
import CachedImage from '@app/components/Common/CachedImage';
import type { ForwardRefRenderFunction, HTMLAttributes } from 'react';
import React, { useEffect, useState } from 'react';
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
backgroundImages: string[];

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { withProperties } from '../../../utils/typeHelpers';
import { withProperties } from '@app/utils/typeHelpers';
interface ListItemProps {
title: string;
className?: string;
children: React.ReactNode;
}
const ListItem: React.FC<ListItemProps> = ({ title, className, children }) => {
const ListItem = ({ title, className, children }: ListItemProps) => {
return (
<div>
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
@@ -22,9 +22,10 @@ const ListItem: React.FC<ListItemProps> = ({ title, className, children }) => {
interface ListProps {
title: string;
subTitle?: string;
children: React.ReactNode;
}
const List: React.FC<ListProps> = ({ title, subTitle, children }) => {
const List = ({ title, subTitle, children }: ListProps) => {
return (
<>
<div>

View File

@@ -1,30 +1,33 @@
import React from 'react';
import { useIntl } from 'react-intl';
import {
import PersonCard from '@app/components/PersonCard';
import TitleCard from '@app/components/TitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
MovieResult,
PersonResult,
TvResult,
} from '../../../../server/models/Search';
import useVerticalScroll from '../../../hooks/useVerticalScroll';
import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard';
import TitleCard from '../../TitleCard';
} from '@server/models/Search';
import { useIntl } from 'react-intl';
interface ListViewProps {
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult)[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
isReachingEnd?: boolean;
onScrollBottom: () => void;
}
};
const ListView: React.FC<ListViewProps> = ({
const ListView = ({
items,
isEmpty,
isLoading,
onScrollBottom,
isReachingEnd,
}) => {
plexItems,
}: ListViewProps) => {
const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
return (
@@ -35,6 +38,18 @@ const ListView: React.FC<ListViewProps> = ({
</div>
)}
<ul className="cards-vertical">
{plexItems?.map((title, index) => {
return (
<li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard
id={title.tmdbId}
tmdbId={title.tmdbId}
type={title.mediaType}
canExpand
/>
</li>
);
})}
{items?.map((title, index) => {
let titleCard: React.ReactNode;

View File

@@ -1,6 +1,4 @@
import React from 'react';
export const SmallLoadingSpinner: React.FC = () => {
export const SmallLoadingSpinner = () => {
return (
<div className="inset-0 flex h-full w-full items-center justify-center text-gray-200">
<svg
@@ -29,7 +27,7 @@ export const SmallLoadingSpinner: React.FC = () => {
);
};
const LoadingSpinner: React.FC = () => {
const LoadingSpinner = () => {
return (
<div className="inset-0 flex h-64 items-center justify-center text-gray-200">
<svg

View File

@@ -1,16 +1,19 @@
import React, { MouseEvent, ReactNode, useRef } from 'react';
import type { ButtonType } from '@app/components/Common/Button';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import useClickOutside from '@app/hooks/useClickOutside';
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import type { MouseEvent } from 'react';
import React, { Fragment, useRef } from 'react';
import ReactDOM from 'react-dom';
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;
subTitle?: string;
onCancel?: (e?: MouseEvent<HTMLElement>) => void;
onOk?: (e?: MouseEvent<HTMLButtonElement>) => void;
onSecondary?: (e?: MouseEvent<HTMLButtonElement>) => void;
@@ -28,87 +31,94 @@ interface ModalProps {
tertiaryButtonType?: ButtonType;
disableScrollLock?: boolean;
backgroundClickable?: boolean;
iconSvg?: ReactNode;
loading?: boolean;
backdrop?: string;
children?: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({
title,
onCancel,
onOk,
cancelText,
okText,
okDisabled = false,
cancelButtonType = 'default',
okButtonType = 'primary',
children,
disableScrollLock,
backgroundClickable = true,
iconSvg,
loading = false,
secondaryButtonType = 'default',
secondaryDisabled = false,
onSecondary,
secondaryText,
tertiaryButtonType = 'default',
tertiaryDisabled = false,
tertiaryText,
onTertiary,
backdrop,
}) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
useClickOutside(modalRef, () => {
typeof onCancel === 'function' && backgroundClickable
? onCancel()
: undefined;
});
useLockBodyScroll(true, disableScrollLock);
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
(
{
title,
subTitle,
onCancel,
onOk,
cancelText,
okText,
okDisabled = false,
cancelButtonType = 'default',
okButtonType = 'primary',
children,
disableScrollLock,
backgroundClickable = true,
secondaryButtonType = 'default',
secondaryDisabled = false,
onSecondary,
secondaryText,
tertiaryButtonType = 'default',
tertiaryDisabled = false,
tertiaryText,
loading = false,
onTertiary,
backdrop,
},
parentRef
) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
useClickOutside(modalRef, () => {
if (onCancel && backgroundClickable) {
onCancel();
}
});
useLockBodyScroll(true, disableScrollLock);
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 h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
onKeyDown={(e) => {
if (e.key === 'Escape') {
typeof onCancel === 'function' && backgroundClickable
? onCancel()
: undefined;
}
}}
>
<Transition
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
return ReactDOM.createPortal(
<Transition.Child
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
ref={parentRef}
>
<div style={{ position: 'absolute' }}>
<LoadingSpinner />
</div>
</Transition>
<Transition
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}
>
<div
className="relative inline-block w-full transform overflow-auto bg-gray-700 px-4 pt-5 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-500 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
<Transition
appear
as={Fragment}
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
>
<div style={{ position: 'absolute' }}>
<LoadingSpinner />
</div>
</Transition>
<Transition
className="hide-scrollbar relative inline-block w-full transform overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
ref={modalRef}
style={{
maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)',
}}
appear
as="div"
enter="transition opacity-0 duration-300 transform scale-75"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition opacity-100 duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}
ref={modalRef}
>
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
@@ -123,30 +133,45 @@ const Modal: React.FC<ModalProps> = ({
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(55, 65, 81, 0.85) 0%, rgba(55, 65, 81, 1) 100%)',
'linear-gradient(180deg, rgba(31, 41, 55, 0.75) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative overflow-x-hidden sm:flex sm:items-center">
{iconSvg && <div className="modal-icon">{iconSvg}</div>}
<div className="relative -mx-4 overflow-x-hidden px-4 pt-0.5 sm:flex sm:items-center">
<div
className={`mt-3 truncate text-center text-white sm:mt-0 sm:text-left ${
iconSvg ? 'sm:ml-4' : 'sm:mb-4'
}`}
className={`mt-3 truncate text-center text-white sm:mt-0 sm:text-left`}
>
{title && (
<span
className="truncate text-lg font-bold leading-6"
id="modal-headline"
>
{title}
</span>
{(title || subTitle) && (
<div className="flex flex-col space-y-1">
{title && (
<span
className="text-overseerr truncate pb-0.5 text-2xl font-bold leading-6"
id="modal-headline"
data-testid="modal-title"
>
{title}
</span>
)}
{subTitle && (
<span
className="truncate text-lg font-semibold leading-6 text-gray-200"
id="modal-headline"
data-testid="modal-title"
>
{subTitle}
</span>
)}
</div>
)}
</div>
</div>
{children && (
<div className="relative mt-4 text-sm leading-5 text-gray-300">
<div
className={`relative mt-4 text-sm leading-5 text-gray-300 ${
!(onCancel || onOk || onSecondary || onTertiary) ? 'mb-3' : ''
}`}
>
{children}
</div>
)}
@@ -158,6 +183,7 @@ const Modal: React.FC<ModalProps> = ({
onClick={onOk}
className="ml-3"
disabled={okDisabled}
data-testid="modal-ok-button"
>
{okText ? okText : 'Ok'}
</Button>
@@ -168,6 +194,7 @@ const Modal: React.FC<ModalProps> = ({
onClick={onSecondary}
className="ml-3"
disabled={secondaryDisabled}
data-testid="modal-secondary-button"
>
{secondaryText}
</Button>
@@ -187,6 +214,7 @@ const Modal: React.FC<ModalProps> = ({
buttonType={cancelButtonType}
onClick={onCancel}
className="ml-3 sm:ml-0"
data-testid="modal-cancel-button"
>
{cancelText
? cancelText
@@ -195,11 +223,13 @@ const Modal: React.FC<ModalProps> = ({
)}
</div>
)}
</div>
</Transition>
</div>,
document.body
);
};
</Transition>
</Transition.Child>,
document.body
);
}
);
Modal.displayName = 'Modal';
export default Modal;

View File

@@ -1,20 +1,20 @@
import React from 'react';
import useSettings from '../../../hooks/useSettings';
import useSettings from '@app/hooks/useSettings';
import Head from 'next/head';
interface PageTitleProps {
title: string | (string | undefined)[];
}
const PageTitle: React.FC<PageTitleProps> = ({ title }) => {
const PageTitle = ({ title }: PageTitleProps) => {
const settings = useSettings();
const titleText = `${
Array.isArray(title) ? title.filter(Boolean).join(' - ') : title
} - ${settings.currentSettings.applicationTitle}`;
return (
<Head>
<title>
{Array.isArray(title) ? title.filter(Boolean).join(' - ') : title} -{' '}
{settings.currentSettings.applicationTitle}
</title>
<title>{titleText}</title>
</Head>
);
};

View File

@@ -1,5 +1,4 @@
import React, { ReactNode } from 'react';
import ButtonWithDropdown from '../ButtonWithDropdown';
import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown';
interface PlayButtonProps {
links: PlayButtonLink[];
@@ -8,10 +7,10 @@ interface PlayButtonProps {
export interface PlayButtonLink {
text: string;
url: string;
svg: ReactNode;
svg: React.ReactNode;
}
const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
const PlayButton = ({ links }: PlayButtonProps) => {
if (!links || !links.length) {
return null;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
interface ProgressCircleProps {
className?: string;
@@ -6,11 +6,11 @@ interface ProgressCircleProps {
useHeatLevel?: boolean;
}
const ProgressCircle: React.FC<ProgressCircleProps> = ({
const ProgressCircle = ({
className,
progress = 0,
useHeatLevel,
}) => {
}: ProgressCircleProps) => {
const ref = useRef<SVGCircleElement>(null);
let color = '';

View File

@@ -1,6 +1,6 @@
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
import { Field } from 'formik';
import React, { useState } from 'react';
import { useState } from 'react';
interface CustomInputProps extends React.ComponentProps<'input'> {
as?: 'input';
@@ -12,10 +12,7 @@ interface CustomFieldProps extends React.ComponentProps<typeof Field> {
type SensitiveInputProps = CustomInputProps | CustomFieldProps;
const SensitiveInput: React.FC<SensitiveInputProps> = ({
as = 'input',
...props
}) => {
const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
const [isHidden, setHidden] = useState(true);
const Component = as === 'input' ? 'input' : Field;
const componentProps =

View File

@@ -1,8 +1,8 @@
import { useUser } from '@app/hooks/useUser';
import type { Permission } from '@server/lib/permissions';
import { hasPermission } from '@server/lib/permissions';
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;
@@ -14,14 +14,17 @@ export interface SettingsRoute {
hidden?: boolean;
}
const SettingsLink: React.FC<{
type SettingsLinkProps = {
tabType: 'default' | 'button';
currentPath: string;
route: string;
regex: RegExp;
hidden?: boolean;
isMobile?: boolean;
}> = ({
children: React.ReactNode;
};
const SettingsLink = ({
children,
tabType,
currentPath,
@@ -29,7 +32,7 @@ const SettingsLink: React.FC<{
regex,
hidden = false,
isMobile = false,
}) => {
}: SettingsLinkProps) => {
if (hidden) {
return null;
}
@@ -65,10 +68,13 @@ const SettingsLink: React.FC<{
);
};
const SettingsTabs: React.FC<{
const SettingsTabs = ({
tabType = 'default',
settingsRoutes,
}: {
tabType?: 'default' | 'button';
settingsRoutes: SettingsRoute[];
}> = ({ tabType = 'default', settingsRoutes }) => {
}) => {
const router = useRouter();
const { user: currentUser } = useUser();
@@ -137,7 +143,7 @@ const SettingsTabs: React.FC<{
</div>
) : (
<div className="hide-scrollbar hidden overflow-x-scroll border-b border-gray-600 sm:block">
<nav className="flex">
<nav className="flex" data-testid="settings-nav-desktop">
{settingsRoutes
.filter(
(route) =>

View File

@@ -1,24 +1,25 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import { Transition } from '@headlessui/react';
import { XIcon } from '@heroicons/react/outline';
import React, { useEffect, useRef, useState } from 'react';
import { Fragment, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import Transition from '../../Transition';
interface SlideOverProps {
show?: boolean;
title: React.ReactNode;
subText?: string;
onClose: () => void;
children: React.ReactNode;
}
const SlideOver: React.FC<SlideOverProps> = ({
const SlideOver = ({
show = false,
title,
subText,
onClose,
children,
}) => {
}: SlideOverProps) => {
const [isMounted, setIsMounted] = useState(false);
const slideoverRef = useRef(null);
useLockBodyScroll(show);
@@ -33,6 +34,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
return ReactDOM.createPortal(
<Transition
as={Fragment}
show={show}
appear
enter="opacity-0 transition ease-in-out duration-300"
@@ -53,9 +55,8 @@ const SlideOver: React.FC<SlideOverProps> = ({
}}
>
<div className="absolute inset-0 overflow-hidden">
<section className="absolute inset-y-0 right-0 flex max-w-full pl-10">
<Transition
show={show}
<section className="absolute inset-y-0 right-0 flex max-w-full">
<Transition.Child
appear
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
@@ -66,20 +67,20 @@ const SlideOver: React.FC<SlideOverProps> = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="w-screen max-w-md"
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<div className="flex h-full flex-col overflow-y-scroll bg-gray-700 shadow-xl">
<header className="slideover space-y-1 bg-indigo-600 px-4">
<div className="hide-scrollbar flex h-full flex-col overflow-y-scroll rounded-lg bg-gray-800 bg-opacity-80 shadow-xl ring-1 ring-gray-700 backdrop-blur">
<header className="space-y-1 border-b border-gray-700 py-4 px-4">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg font-bold leading-7 text-white">
<h2 className="text-overseerr text-2xl font-bold leading-7">
{title}
</h2>
<div className="flex h-7 items-center">
<button
aria-label="Close panel"
className="text-indigo-200 transition duration-150 ease-in-out hover:text-white"
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<XIcon className="h-6 w-6" />
@@ -88,7 +89,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
</div>
{subText && (
<div>
<p className="text-sm leading-5 text-indigo-300">
<p className="font-semibold leading-5 text-gray-300">
{subText}
</p>
</div>
@@ -99,7 +100,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
</div>
</div>
</div>
</Transition>
</Transition.Child>
</section>
</div>
</div>

View File

@@ -1,17 +1,20 @@
import React, { AllHTMLAttributes } from 'react';
import { withProperties } from '../../../utils/typeHelpers';
import { withProperties } from '@app/utils/typeHelpers';
const TBody: React.FC = ({ children }) => {
type TBodyProps = {
children: React.ReactNode;
};
const TBody = ({ children }: TBodyProps) => {
return (
<tbody className="divide-y divide-gray-700 bg-gray-800">{children}</tbody>
);
};
const TH: React.FC<AllHTMLAttributes<HTMLTableHeaderCellElement>> = ({
const TH = ({
children,
className,
...props
}) => {
}: React.ComponentPropsWithoutRef<'th'>) => {
const style = [
'px-4 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider truncate',
];
@@ -27,18 +30,18 @@ const TH: React.FC<AllHTMLAttributes<HTMLTableHeaderCellElement>> = ({
);
};
interface TDProps extends AllHTMLAttributes<HTMLTableCellElement> {
type TDProps = {
alignText?: 'left' | 'center' | 'right';
noPadding?: boolean;
}
};
const TD: React.FC<TDProps> = ({
const TD = ({
children,
alignText = 'left',
noPadding,
className,
...props
}) => {
}: TDProps & React.ComponentPropsWithoutRef<'td'>) => {
const style = ['text-sm leading-5 text-white'];
switch (alignText) {
@@ -68,7 +71,11 @@ const TD: React.FC<TDProps> = ({
);
};
const Table: React.FC = ({ children }) => {
type TableProps = {
children: React.ReactNode;
};
const Table = ({ children }: TableProps) => {
return (
<div className="flex flex-col">
<div className="my-2 -mx-4 overflow-x-auto md:mx-0 lg:mx-0">

View File

@@ -0,0 +1,38 @@
import React from 'react';
import type { Config } from 'react-popper-tooltip';
import { usePopperTooltip } from 'react-popper-tooltip';
type TooltipProps = {
content: React.ReactNode;
children: React.ReactElement;
tooltipConfig?: Partial<Config>;
};
const Tooltip = ({ children, content, tooltipConfig }: TooltipProps) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
followCursor: true,
offset: [-28, 6],
placement: 'auto-end',
...tooltipConfig,
});
return (
<>
{React.cloneElement(children, { ref: setTriggerRef })}
{visible && (
<div
ref={setTooltipRef}
{...getTooltipProps({
className:
'z-50 text-sm font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
})}
>
{content}
</div>
)}
</>
);
};
export default Tooltip;

View File

@@ -1,5 +1,5 @@
import Link from 'next/link';
import React, { useState } from 'react';
import { useState } from 'react';
interface CompanyCardProps {
name: string;
@@ -7,7 +7,7 @@ interface CompanyCardProps {
url: string;
}
const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
const CompanyCard = ({ image, url, name }: CompanyCardProps) => {
const [isHovered, setHovered] = useState(false);
return (

View File

@@ -1,19 +1,18 @@
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 Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
genreMovies: '{genre} Movies',
});
const DiscoverMovieGenre: React.FC = () => {
const DiscoverMovieGenre = () => {
const router = useRouter();
const intl = useIntl();

View File

@@ -1,19 +1,18 @@
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 Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
languageMovies: '{language} Movies',
});
const DiscoverMovieLanguage: React.FC = () => {
const DiscoverMovieLanguage = () => {
const router = useRouter();
const intl = useIntl();

View File

@@ -1,17 +1,16 @@
import React from 'react';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { MovieResult } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
discovermovies: 'Popular Movies',
});
const DiscoverMovies: React.FC = () => {
const DiscoverMovies = () => {
const intl = useIntl();
const {

View File

@@ -1,20 +1,19 @@
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 Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TvNetwork } from '@server/models/common';
import type { TvResult } from '@server/models/Search';
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';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
networkSeries: '{network} Series',
});
const DiscoverTvNetwork: React.FC = () => {
const DiscoverTvNetwork = () => {
const router = useRouter();
const intl = useIntl();

View File

@@ -1,20 +1,19 @@
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 Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { ProductionCompany } from '@server/models/common';
import type { MovieResult } from '@server/models/Search';
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';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
studioMovies: '{studio} Movies',
});
const DiscoverMovieStudio: React.FC = () => {
const DiscoverMovieStudio = () => {
const router = useRouter();
const intl = useIntl();

View File

@@ -1,17 +1,16 @@
import React from 'react';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { TvResult } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
discovertv: 'Popular Series',
});
const DiscoverTv: React.FC = () => {
const DiscoverTv = () => {
const intl = useIntl();
const {

View File

@@ -1,19 +1,18 @@
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 Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
genreSeries: '{genre} Series',
});
const DiscoverTvGenre: React.FC = () => {
const DiscoverTvGenre = () => {
const router = useRouter();
const intl = useIntl();

View File

@@ -1,19 +1,18 @@
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 Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import globalMessages from '../../../i18n/globalMessages';
import useDiscover from '../../../hooks/useDiscover';
import Error from '../../../pages/_error';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
languageSeries: '{language} Series',
});
const DiscoverTvLanguage: React.FC = () => {
const DiscoverTvLanguage = () => {
const router = useRouter();
const intl = useIntl();

View File

@@ -1,17 +1,16 @@
import React from 'react';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { TvResult } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
upcomingtv: 'Upcoming Series',
});
const DiscoverTvUpcoming: React.FC = () => {
const DiscoverTvUpcoming = () => {
const intl = useIntl();
const {

View File

@@ -0,0 +1,84 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import { useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discoverwatchlist: 'Your Plex Watchlist',
watchlist: 'Plex Watchlist',
});
const DiscoverWatchlist = () => {
const intl = useIntl();
const router = useRouter();
const { user } = useUser({
id: Number(router.query.userId),
});
const { user: currentUser } = useUser();
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<WatchlistItem>(
`/api/v1/${
router.pathname.startsWith('/profile')
? `user/${currentUser?.id}`
: router.query.userId
? `user/${router.query.userId}`
: 'discover'
}/watchlist`
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(
router.query.userId ? messages.watchlist : messages.discoverwatchlist
);
return (
<>
<PageTitle
title={[title, router.query.userId ? user?.displayName : '']}
/>
<div className="mt-1 mb-5">
<Header
subtext={
router.query.userId ? (
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
) : (
''
)
}
>
{title}
</Header>
</div>
<ListView
plexItems={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverWatchlist;

View File

@@ -1,19 +1,18 @@
import React from 'react';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Error from '@app/pages/_error';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
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 MovieGenreList = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie`

View File

@@ -1,18 +1,18 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
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 MovieGenreSlider = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie`,

View File

@@ -1,7 +1,6 @@
import React from 'react';
import CompanyCard from '@app/components/CompanyCard';
import Slider from '@app/components/Slider';
import { defineMessages, useIntl } from 'react-intl';
import CompanyCard from '../../CompanyCard';
import Slider from '../../Slider';
const messages = defineMessages({
networks: 'Networks',
@@ -142,7 +141,7 @@ const networks: Network[] = [
},
];
const NetworkSlider: React.FC = () => {
const NetworkSlider = () => {
const intl = useIntl();
return (

View File

@@ -1,7 +1,6 @@
import React from 'react';
import CompanyCard from '@app/components/CompanyCard';
import Slider from '@app/components/Slider';
import { defineMessages, useIntl } from 'react-intl';
import CompanyCard from '../../CompanyCard';
import Slider from '../../Slider';
const messages = defineMessages({
studios: 'Studios',
@@ -21,10 +20,10 @@ const studios: Studio[] = [
url: '/discover/movies/studio/2',
},
{
name: '20th Century Fox',
name: '20th Century Studios',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
url: '/discover/movies/studio/25',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/h0rjX5vjW5r8yEnUBStFarjcLT4.png',
url: '/discover/movies/studio/127928',
},
{
name: 'Sony Pictures',
@@ -76,7 +75,7 @@ const studios: Studio[] = [
},
];
const StudioSlider: React.FC = () => {
const StudioSlider = () => {
const intl = useIntl();
return (

View File

@@ -1,21 +1,20 @@
import React from 'react';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type {
MovieResult,
TvResult,
PersonResult,
} from '../../../server/models/Search';
import ListView from '../Common/ListView';
TvResult,
} from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
trending: 'Trending',
});
const Trending: React.FC = () => {
const Trending = () => {
const intl = useIntl();
const {
isLoadingInitialData,

View File

@@ -1,19 +1,18 @@
import React from 'react';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Error from '@app/pages/_error';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
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 TvGenreList = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv`

View File

@@ -1,18 +1,18 @@
import { genreColorMap } from '@app/components/Discover/constants';
import GenreCard from '@app/components/GenreCard';
import Slider from '@app/components/Slider';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
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 TvGenreSlider = () => {
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv`,

View File

@@ -1,17 +1,16 @@
import React from 'react';
import type { MovieResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { MovieResult } from '@server/models/Search';
import { defineMessages, useIntl } from 'react-intl';
import Header from '../Common/Header';
import PageTitle from '../Common/PageTitle';
import useDiscover from '../../hooks/useDiscover';
import Error from '../../pages/_error';
const messages = defineMessages({
upcomingmovies: 'Upcoming Movies',
});
const UpcomingMovies: React.FC = () => {
const UpcomingMovies = () => {
const intl = useIntl();
const {

View File

@@ -1,19 +1,20 @@
import PageTitle from '@app/components/Common/PageTitle';
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider';
import StudioSlider from '@app/components/Discover/StudioSlider';
import TvGenreSlider from '@app/components/Discover/TvGenreSlider';
import MediaSlider from '@app/components/MediaSlider';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
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 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',
@@ -22,13 +23,16 @@ const messages = defineMessages({
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
noRequests: 'No requests.',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
const Discover: React.FC = () => {
const Discover = () => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
@@ -38,50 +42,114 @@ const Discover: React.FC = () => {
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
{
revalidateOnMount: true,
}
);
const { data: watchlistItems, error: watchlistError } = useSWR<{
page: number;
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media && !mediaError}
isEmpty={!!media && !mediaError && media.results.length === 0}
items={media?.results?.map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
tmdbId={item.tmdbId}
type={item.mediaType}
{(!media || !!media.results.length) &&
!mediaError &&
hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
}) && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
{(!requests || !!requests.results.length) && !requestError && (
<>
<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"
isLoading={!requests}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
/>
))}
/>
<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"
isLoading={!requests && !requestError}
isEmpty={!!requests && !requestError && requests.results.length === 0}
items={(requests?.results ?? []).map((request) => (
<RequestCard
key={`request-slider-item-${request.id}`}
request={request}
/>
))}
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.noRequests)}
/>
</>
)}
{user?.userType === UserType.PLEX &&
(!watchlistItems ||
!!watchlistItems.results.length ||
user.settings?.watchlistSyncMovies ||
user.settings?.watchlistSyncTv) &&
!watchlistError && (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowCircleRightIcon />
</a>
</Link>
</div>
<Slider
sliderKey="watchlist"
isLoading={!watchlistItems}
isEmpty={!!watchlistItems && watchlistItems.results.length === 0}
emptyMessage={intl.formatMessage(messages.emptywatchlist, {
PlexWatchlistSupportLink: (msg: React.ReactNode) => (
<a
href="https://support.plex.tv/articles/universal-watchlist/"
className="text-white transition duration-300 hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
/>
))}
/>
</>
)}
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import Badge from '@app/components/Common/Badge';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { DownloadingItem } from '../../../server/lib/downloadtracker';
import Badge from '../Common/Badge';
const messages = defineMessages({
estimatedtime: 'Estimated {time}',
@@ -12,10 +11,7 @@ interface DownloadBlockProps {
is4k?: boolean;
}
const DownloadBlock: React.FC<DownloadBlockProps> = ({
downloadItem,
is4k = false,
}) => {
const DownloadBlock = ({ downloadItem, is4k = false }: DownloadBlockProps) => {
const intl = useIntl();
return (

View File

@@ -1,15 +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 TraktLogo from '../../assets/services/trakt.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
import ImdbLogo from '@app/assets/services/imdb.svg';
import JellyfinLogo from '@app/assets/services/jellyfin.svg';
import PlexLogo from '@app/assets/services/plex.svg';
import RTLogo from '@app/assets/services/rt.svg';
import TmdbLogo from '@app/assets/services/tmdb.svg';
import TraktLogo from '@app/assets/services/trakt.svg';
import TvdbLogo from '@app/assets/services/tvdb.svg';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
@@ -20,14 +19,14 @@ interface ExternalLinkBlockProps {
mediaUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
const ExternalLinkBlock = ({
mediaType,
tmdbId,
tvdbId,
imdbId,
rtUrl,
mediaUrl,
}) => {
}: ExternalLinkBlockProps) => {
const settings = useSettings();
const { locale } = useLocale();
@@ -79,7 +78,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
)}
{rtUrl && (
<a
href={`${rtUrl}`}
href={rtUrl}
className="w-14 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"

View File

@@ -1,7 +1,7 @@
import CachedImage from '@app/components/Common/CachedImage';
import { withProperties } from '@app/utils/typeHelpers';
import Link from 'next/link';
import React, { useState } from 'react';
import { withProperties } from '../../utils/typeHelpers';
import CachedImage from '../Common/CachedImage';
import { useState } from 'react';
interface GenreCardProps {
name: string;
@@ -10,12 +10,7 @@ interface GenreCardProps {
canExpand?: boolean;
}
const GenreCard: React.FC<GenreCardProps> = ({
image,
url,
name,
canExpand = false,
}) => {
const GenreCard = ({ image, url, name, canExpand = false }: GenreCardProps) => {
const [isHovered, setHovered] = useState(false);
return (
@@ -54,7 +49,7 @@ const GenreCard: React.FC<GenreCardProps> = ({
);
};
const GenreCardPlaceholder: React.FC = () => {
const GenreCardPlaceholder = () => {
return (
<div
className={`relative h-32 w-56 animate-pulse rounded-xl bg-gray-700 sm:h-40 sm:w-72`}

View File

@@ -1,22 +1,21 @@
import Button from '@app/components/Common/Button';
import { issueOptions } from '@app/components/IssueModal/constants';
import { useUser } from '@app/hooks/useUser';
import {
CalendarIcon,
ExclamationIcon,
EyeIcon,
UserIcon,
} from '@heroicons/react/solid';
import type Issue from '@server/entity/Issue';
import Link from 'next/link';
import React from 'react';
import { useIntl } from 'react-intl';
import type Issue from '../../../server/entity/Issue';
import { useUser } from '../../hooks/useUser';
import Button from '../Common/Button';
import { issueOptions } from '../IssueModal/constants';
interface IssueBlockProps {
issue: Issue;
}
const IssueBlock: React.FC<IssueBlockProps> = ({ issue }) => {
const IssueBlock = ({ issue }: IssueBlockProps) => {
const { user } = useUser();
const intl = useIntl();
const issueOption = issueOptions.find(

View File

@@ -1,18 +1,16 @@
import { Menu } from '@headlessui/react';
import { ExclamationIcon } from '@heroicons/react/outline';
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser';
import { Menu, Transition } from '@headlessui/react';
import { DotsVerticalIcon } from '@heroicons/react/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import React, { useState } from 'react';
import { Fragment, useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
import * as Yup from 'yup';
import type { default as IssueCommentType } from '../../../../server/entity/IssueComment';
import { Permission, useUser } from '../../../hooks/useUser';
import Button from '../../Common/Button';
import Modal from '../../Common/Modal';
import Transition from '../../Transition';
const messages = defineMessages({
postedby: 'Posted {relativeTime} by {username}',
@@ -30,12 +28,12 @@ interface IssueCommentProps {
onUpdate?: () => void;
}
const IssueComment: React.FC<IssueCommentProps> = ({
const IssueComment = ({
comment,
isReversed = false,
isActiveUser = false,
onUpdate,
}) => {
}: IssueCommentProps) => {
const intl = useIntl();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -66,6 +64,7 @@ const IssueComment: React.FC<IssueCommentProps> = ({
} mt-4 space-x-4`}
>
<Transition
as={Fragment}
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -80,7 +79,6 @@ const IssueComment: React.FC<IssueCommentProps> = ({
onOk={() => deleteComment()}
okText={intl.formatMessage(messages.delete)}
okButtonType="danger"
iconSvg={<ExclamationIcon />}
>
{intl.formatMessage(messages.areyousuredelete)}
</Modal>
@@ -114,6 +112,7 @@ const IssueComment: React.FC<IssueCommentProps> = ({
</div>
<Transition
as={Fragment}
show={open}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
@@ -195,9 +194,11 @@ const IssueComment: React.FC<IssueCommentProps> = ({
name="newMessage"
className="h-24"
/>
{errors.newMessage && touched.newMessage && (
<div className="error">{errors.newMessage}</div>
)}
{errors.newMessage &&
touched.newMessage &&
typeof errors.newMessage === 'string' && (
<div className="error">{errors.newMessage}</div>
)}
<div className="mt-4 flex items-center justify-end space-x-2">
<Button
type="button"

View File

@@ -1,12 +1,12 @@
import Button from '@app/components/Common/Button';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Menu, Transition } from '@headlessui/react';
import { DotsVerticalIcon } from '@heroicons/react/solid';
import { Field, Form, Formik } from 'formik';
import React, { Fragment, useState } from 'react';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
import { Permission, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
const messages = defineMessages({
description: 'Description',
@@ -22,13 +22,13 @@ interface IssueDescriptionProps {
onDelete: () => void;
}
const IssueDescription: React.FC<IssueDescriptionProps> = ({
const IssueDescription = ({
description,
belongsToUser,
commentCount,
onEdit,
onDelete,
}) => {
}: IssueDescriptionProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
const [isEditing, setIsEditing] = useState(false);
@@ -52,7 +52,7 @@ const IssueDescription: React.FC<IssueDescriptionProps> = ({
<Transition
show={open}
as={Fragment}
as="div"
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"

View File

@@ -1,41 +1,40 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import IssueComment from '@app/components/IssueDetails/IssueComment';
import IssueDescription from '@app/components/IssueDetails/IssueDescription';
import { issueOptions } from '@app/components/IssueModal/constants';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { Transition } from '@headlessui/react';
import {
ChatIcon,
CheckCircleIcon,
ExclamationIcon,
PlayIcon,
ServerIcon,
} from '@heroicons/react/outline';
import { RefreshIcon } from '@heroicons/react/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaType } from '../../../server/constants/media';
import type Issue from '../../../server/entity/Issue';
import type { MovieDetails } from '../../../server/models/Movie';
import type { TvDetails } from '../../../server/models/Tv';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import CachedImage from '../Common/CachedImage';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import PageTitle from '../Common/PageTitle';
import { issueOptions } from '../IssueModal/constants';
import Transition from '../Transition';
import IssueComment from './IssueComment';
import IssueDescription from './IssueDescription';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import getConfig from 'next/config';
const messages = defineMessages({
openedby: '#{issueId} opened {relativeTime} by {username}',
@@ -77,7 +76,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const IssueDetails: React.FC = () => {
const IssueDetails = () => {
const { addToast } = useToasts();
const router = useRouter();
const intl = useIntl();
@@ -179,6 +178,7 @@ const IssueDetails: React.FC = () => {
>
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition
as="div"
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -193,7 +193,6 @@ const IssueDetails: React.FC = () => {
onOk={() => deleteIssue()}
okText={intl.formatMessage(messages.deleteissue)}
okButtonType="danger"
iconSvg={<ExclamationIcon />}
>
{intl.formatMessage(messages.deleteissueconfirm)}
</Modal>

View File

@@ -1,20 +1,19 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import { issueOptions } from '@app/components/IssueModal/constants';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { EyeIcon } from '@heroicons/react/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import useSWR from 'swr';
import { IssueStatus } from '../../../../server/constants/issue';
import { MediaType } from '../../../../server/constants/media';
import Issue from '../../../../server/entity/Issue';
import { MovieDetails } from '../../../../server/models/Movie';
import { TvDetails } from '../../../../server/models/Tv';
import { Permission, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages';
import Badge from '../../Common/Badge';
import Button from '../../Common/Button';
import CachedImage from '../../Common/CachedImage';
import { issueOptions } from '../../IssueModal/constants';
const messages = defineMessages({
openeduserdate: '{date} by {user}',
@@ -36,7 +35,7 @@ interface IssueItemProps {
issue: Issue;
}
const IssueItem: React.FC<IssueItemProps> = ({ issue }) => {
const IssueItem = ({ issue }: IssueItemProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
const { ref, inView } = useInView({

View File

@@ -1,21 +1,21 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import IssueItem from '@app/components/IssueList/IssueItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import globalMessages from '@app/i18n/globalMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { IssueResultsResponse } from '../../../server/interfaces/api/issueInterfaces';
import Button from '../../components/Common/Button';
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import globalMessages from '../../i18n/globalMessages';
import Header from '../Common/Header';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import IssueItem from './IssueItem';
const messages = defineMessages({
issues: 'Issues',
@@ -32,7 +32,7 @@ enum Filter {
type Sort = 'added' | 'modified';
const IssueList: React.FC = () => {
const IssueList = () => {
const intl = useIntl();
const router = useRouter();
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.OPEN);
@@ -194,9 +194,9 @@ const IssueList: React.FC = () => {
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
strong: (msg: React.ReactNode) => (
<span className="font-medium">{msg}</span>
),
})}
</p>
</div>

View File

@@ -1,28 +1,25 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
import { issueOptions } from '@app/components/IssueModal/constants';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { RadioGroup } from '@headlessui/react';
import { ExclamationIcon } from '@heroicons/react/outline';
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import { Field, Formik } from 'formik';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import { MediaStatus } from '../../../../server/constants/media';
import type Issue from '../../../../server/entity/Issue';
import { MovieDetails } from '../../../../server/models/Movie';
import { TvDetails } from '../../../../server/models/Tv';
import useSettings from '../../../hooks/useSettings';
import { Permission, useUser } from '../../../hooks/useUser';
import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
import Modal from '../../Common/Modal';
import { issueOptions } from '../constants';
const messages = defineMessages({
validationMessageRequired: 'You must provide a description',
issomethingwrong: 'Is there a problem with {title}?',
whatswrong: "What's wrong?",
providedetail:
'Please provide a detailed explanation of the issue you encountered.',
@@ -55,11 +52,11 @@ interface CreateIssueModalProps {
onCancel?: () => void;
}
const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
const CreateIssueModal = ({
onCancel,
mediaType,
tmdbId,
}) => {
}: CreateIssueModalProps) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser();
@@ -118,9 +115,7 @@ const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
<div>
{intl.formatMessage(messages.toastSuccessCreate, {
title: isMovie(data) ? data.title : data.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</div>
<Link href={`/issues/${newIssue.data.id}`}>
@@ -153,23 +148,14 @@ const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
<Modal
backgroundClickable
onCancel={onCancel}
iconSvg={<ExclamationIcon />}
title={intl.formatMessage(messages.reportissue)}
subTitle={data && isMovie(data) ? data?.title : data?.name}
cancelText={intl.formatMessage(globalMessages.close)}
onOk={() => handleSubmit()}
okText={intl.formatMessage(messages.submitissue)}
loading={!data && !error}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{data && (
<div className="flex items-center">
<span className="mr-1 font-semibold">
{intl.formatMessage(messages.issomethingwrong, {
title: isMovie(data) ? data.title : data.name,
})}
</span>
</div>
)}
{mediaType === 'tv' && data && !isMovie(data) && (
<>
<div className="form-row">
@@ -267,7 +253,7 @@ const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
? 'rounded-bl-md rounded-br-md'
: '',
checked
? 'z-10 border-indigo-500 bg-indigo-600'
? 'z-10 border border-indigo-500 bg-indigo-400 bg-opacity-20'
: 'border-gray-500',
'relative flex cursor-pointer border p-4 focus:outline-none'
)
@@ -278,7 +264,7 @@ const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
<span
className={`${
checked
? 'border-transparent bg-indigo-800'
? 'border-transparent bg-indigo-600'
: 'border-gray-300 bg-white'
} ${
active ? 'ring-2 ring-indigo-300 ring-offset-2' : ''
@@ -315,9 +301,11 @@ const CreateIssueModal: React.FC<CreateIssueModalProps> = ({
className="h-28"
placeholder={intl.formatMessage(messages.providedetail)}
/>
{errors.message && touched.message && (
<div className="error">{errors.message}</div>
)}
{errors.message &&
touched.message &&
typeof errors.message === 'string' && (
<div className="error">{errors.message}</div>
)}
</div>
</Modal>
);

View File

@@ -1,5 +1,6 @@
import { defineMessages, MessageDescriptor } from 'react-intl';
import { IssueType } from '../../../server/constants/issue';
import { IssueType } from '@server/constants/issue';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages } from 'react-intl';
const messages = defineMessages({
issueAudio: 'Audio',

View File

@@ -1,6 +1,5 @@
import React from 'react';
import Transition from '../Transition';
import CreateIssueModal from './CreateIssueModal';
import CreateIssueModal from '@app/components/IssueModal/CreateIssueModal';
import { Transition } from '@headlessui/react';
interface IssueModalProps {
show?: boolean;
@@ -10,13 +9,9 @@ interface IssueModalProps {
issueId?: never;
}
const IssueModal: React.FC<IssueModalProps> = ({
show,
mediaType,
onCancel,
tmdbId,
}) => (
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
<Transition
as="div"
enter="transition opacity-0 duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"

View File

@@ -1,20 +1,15 @@
import React, { HTMLAttributes } from 'react';
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/ace';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/theme-dracula';
import type { HTMLAttributes } from 'react';
import AceEditor from 'react-ace';
interface JSONEditorProps extends HTMLAttributes<HTMLDivElement> {
name: string;
value: string;
onUpdate: (value: string) => void;
}
const JSONEditor: React.FC<JSONEditorProps> = ({
name,
value,
onUpdate,
onBlur,
}) => {
const JSONEditor = ({ name, value, onUpdate, onBlur }: JSONEditorProps) => {
return (
<div className="w-full overflow-hidden rounded-md">
<AceEditor

View File

@@ -1,10 +1,11 @@
import globalMessages from '@app/i18n/globalMessages';
import type { Language } from '@server/lib/settings';
import { sortBy } from 'lodash';
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Select, { CSSObjectWithLabel } from 'react-select';
import type { CSSObjectWithLabel } from 'react-select';
import Select from 'react-select';
import useSWR from 'swr';
import { Language } from '../../../server/lib/settings';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
originalLanguageDefault: 'All Languages',
@@ -33,12 +34,12 @@ interface LanguageSelectorProps {
isUserSettings?: boolean;
}
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
const LanguageSelector = ({
value,
setFieldValue,
serverValue,
isUserSettings = false,
}) => {
}: LanguageSelectorProps) => {
const intl = useIntl();
const { data: languages } = useSWR<Language[]>('/api/v1/languages');

View File

@@ -1,19 +1,17 @@
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext';
import useClickOutside from '@app/hooks/useClickOutside';
import useLocale from '@app/hooks/useLocale';
import { Transition } from '@headlessui/react';
import { TranslateIcon } from '@heroicons/react/solid';
import React, { useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
availableLanguages,
AvailableLocale,
} from '../../../context/LanguageContext';
import useClickOutside from '../../../hooks/useClickOutside';
import useLocale from '../../../hooks/useLocale';
import Transition from '../../Transition';
const messages = defineMessages({
displaylanguage: 'Display Language',
});
const LanguagePicker: React.FC = () => {
const LanguagePicker = () => {
const intl = useIntl();
const dropdownRef = useRef<HTMLDivElement>(null);
const { locale, setLocale } = useLocale();
@@ -34,6 +32,7 @@ const LanguagePicker: React.FC = () => {
</button>
</div>
<Transition
as="div"
show={isDropdownOpen}
enter="transition ease-out duration-100 opacity-0"
enterFrom="transform opacity-0 scale-95"

View File

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

View File

@@ -1,14 +1,13 @@
import useSearchInput from '@app/hooks/useSearchInput';
import { XCircleIcon } from '@heroicons/react/outline';
import { SearchIcon } from '@heroicons/react/solid';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSearchInput from '../../../hooks/useSearchInput';
const messages = defineMessages({
searchPlaceholder: 'Search Movies & TV',
});
const SearchInput: React.FC = () => {
const SearchInput = () => {
const intl = useIntl();
const { searchValue, setSearchValue, setIsOpen, clear } = useSearchInput();
return (

View File

@@ -1,3 +1,8 @@
import UserWarnings from '@app/components/Layout/UserWarnings';
import VersionStatus from '@app/components/Layout/VersionStatus';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
import { Transition } from '@headlessui/react';
import {
ClockIcon,
CogIcon,
@@ -8,13 +13,8 @@ import {
} from '@heroicons/react/outline';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactNode, useRef } from 'react';
import { Fragment, 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';
import UserWarnings from '../UserWarnings';
const messages = defineMessages({
dashboard: 'Discover',
@@ -31,12 +31,13 @@ interface SidebarProps {
interface SidebarLinkProps {
href: string;
svgIcon: ReactNode;
svgIcon: React.ReactNode;
messagesKey: keyof typeof messages;
activeRegExp: RegExp;
as?: string;
requiredPermission?: Permission | Permission[];
permissionType?: 'and' | 'or';
dataTestId?: string;
}
const SidebarLinks: SidebarLinkProps[] = [
@@ -72,17 +73,19 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/users/,
requiredPermission: Permission.MANAGE_USERS,
dataTestId: 'sidebar-menu-users',
},
{
href: '/settings',
messagesKey: 'settings',
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/settings/,
requiredPermission: Permission.MANAGE_SETTINGS,
requiredPermission: Permission.ADMIN,
dataTestId: 'sidebar-menu-settings',
},
];
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
const Sidebar = ({ open, setClosed }: SidebarProps) => {
const navRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const intl = useIntl();
@@ -92,9 +95,10 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
return (
<>
<div className="lg:hidden">
<Transition show={open}>
<Transition as={Fragment} show={open}>
<div className="fixed inset-0 z-40 flex">
<Transition
<Transition.Child
as="div"
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -105,8 +109,9 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
<div className="fixed inset-0">
<div className="absolute inset-0 bg-gray-900 opacity-90"></div>
</div>
</Transition>
<Transition
</Transition.Child>
<Transition.Child
as="div"
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
@@ -115,7 +120,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
leaveTo="-translate-x-full"
>
<>
<div className="sidebar relative flex w-full max-w-xs flex-1 flex-col bg-gray-800">
<div className="sidebar relative flex h-full w-full max-w-xs flex-1 flex-col bg-gray-800">
<div className="sidebar-close-button absolute top-0 right-0 -mr-14 p-1">
<button
className="flex h-12 w-12 items-center justify-center rounded-full focus:bg-gray-600 focus:outline-none"
@@ -127,7 +132,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</div>
<div
ref={navRef}
className="flex h-0 flex-1 flex-col overflow-y-auto pt-8 pb-8 sm:pb-4"
className="flex flex-1 flex-col overflow-y-auto pt-8 pb-8 sm:pb-4"
>
<div className="flex flex-shrink-0 items-center px-2">
<span className="px-4 text-xl text-gray-50">
@@ -168,6 +173,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={`${sidebarLink.dataTestId}-mobile`}
>
{sidebarLink.svgIcon}
{intl.formatMessage(
@@ -193,7 +199,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
{/* <!-- Force sidebar to shrink to fit close icon --> */}
</div>
</>
</Transition>
</Transition.Child>
</div>
</Transition>
</div>
@@ -233,6 +239,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
: 'hover:bg-gray-700 focus:bg-gray-700'
}
`}
data-testid={sidebarLink.dataTestId}
>
{sidebarLink.svgIcon}
{intl.formatMessage(messages[sidebarLink.messagesKey])}

View File

@@ -0,0 +1,93 @@
import Infinity from '@app/assets/infinity.svg';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import ProgressCircle from '@app/components/Common/ProgressCircle';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
movierequests: 'Movie Requests',
seriesrequests: 'Series Requests',
});
type MiniQuotaDisplayProps = {
userId: number;
};
const MiniQuotaDisplay = ({ userId }: MiniQuotaDisplayProps) => {
const intl = useIntl();
const { data, error } = useSWR<QuotaResponse>(`/api/v1/user/${userId}/quota`);
if (error) {
return null;
}
if (!data && !error) {
return <SmallLoadingSpinner />;
}
return (
<>
{((data?.movie.limit ?? 0) !== 0 || (data?.tv.limit ?? 0) !== 0) && (
<div className="flex">
<div className="flex basis-1/2 flex-col space-y-2">
<div className="text-sm text-gray-200">
{intl.formatMessage(messages.movierequests)}
</div>
<div className="flex h-full items-center space-x-2 text-gray-200">
{data?.movie.limit ?? 0 > 0 ? (
<>
<ProgressCircle
className="h-8 w-8"
progress={Math.round(
((data?.movie.remaining ?? 0) /
(data?.movie.limit ?? 1)) *
100
)}
useHeatLevel
/>
<span className="text-lg font-bold">
{data?.movie.remaining} / {data?.movie.limit}
</span>
</>
) : (
<>
<Infinity className="w-7" />
<span className="font-bold">Unlimited</span>
</>
)}
</div>
</div>
<div className="flex basis-1/2 flex-col space-y-2">
<div className="text-sm text-gray-200">
{intl.formatMessage(messages.seriesrequests)}
</div>
<div className="flex h-full items-center space-x-2 text-gray-200">
{data?.tv.limit ?? 0 > 0 ? (
<>
<ProgressCircle
className="h-8 w-8"
progress={Math.round(
((data?.tv.remaining ?? 0) / (data?.tv.limit ?? 1)) * 100
)}
useHeatLevel
/>
<span className="text-lg font-bold text-gray-200">
{data?.tv.remaining} / {data?.tv.limit}
</span>
</>
) : (
<>
<Infinity className="w-7" />
<span className="font-bold">Unlimited</span>
</>
)}
</div>
</div>
</div>
)}
</>
);
};
export default MiniQuotaDisplay;

View File

@@ -1,25 +1,39 @@
import { LogoutIcon } from '@heroicons/react/outline';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import { Menu, Transition } from '@headlessui/react';
import { ClockIcon, LogoutIcon } from '@heroicons/react/outline';
import { CogIcon, UserIcon } from '@heroicons/react/solid';
import axios from 'axios';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import React, { useRef, useState } from 'react';
import { forwardRef, Fragment } 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',
settings: 'Settings',
requests: 'Requests',
signout: 'Sign Out',
});
const UserDropdown: React.FC = () => {
const ForwardedLink = forwardRef<
HTMLAnchorElement,
LinkProps & React.ComponentPropsWithoutRef<'a'>
>(({ href, children, ...rest }, ref) => {
return (
<Link href={href}>
<a ref={ref} {...rest}>
{children}
</a>
</Link>
);
});
ForwardedLink.displayName = 'ForwardedLink';
const UserDropdown = () => {
const intl = useIntl();
const dropdownRef = useRef<HTMLDivElement>(null);
const { user, revalidate } = useUser();
const [isDropdownOpen, setDropdownOpen] = useState(false);
useClickOutside(dropdownRef, () => setDropdownOpen(false));
const logout = async () => {
const response = await axios.post('/api/v1/auth/logout');
@@ -30,86 +44,119 @@ const UserDropdown: React.FC = () => {
};
return (
<div className="relative ml-3">
<Menu as="div" className="relative ml-3">
<div>
<button
<Menu.Button
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
id="user-menu"
aria-label="User menu"
aria-haspopup="true"
onClick={() => setDropdownOpen(true)}
data-testid="user-menu"
>
<img
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar}
alt=""
/>
</button>
</Menu.Button>
</div>
<Transition
show={isDropdownOpen}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
appear
>
<div
className="absolute right-0 mt-2 w-48 origin-top-right rounded-md shadow-lg"
ref={dropdownRef}
>
<div
className="rounded-md bg-gray-700 py-1 ring-1 ring-black ring-opacity-5"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu"
>
<Link href={`/profile`}>
<a
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) => {
if (e.key === 'Enter') {
setDropdownOpen(false);
}
}}
onClick={() => setDropdownOpen(false)}
>
<UserIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.myprofile)}</span>
</a>
</Link>
<Link href={`/profile/settings`}>
<a
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) => {
if (e.key === 'Enter') {
setDropdownOpen(false);
}
}}
onClick={() => setDropdownOpen(false)}
>
<CogIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.settings)}</span>
</a>
</Link>
<a
href="#"
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()}
>
<LogoutIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.signout)}</span>
</a>
<Menu.Items className="absolute right-0 mt-2 w-72 origin-top-right rounded-md shadow-lg">
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2">
<img
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user?.avatar}
alt=""
/>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xl font-semibold text-gray-200">
{user?.displayName}
</span>
<span className="truncate text-sm text-gray-400">
{user?.email}
</span>
</div>
</div>
{user && <MiniQuotaDisplay userId={user?.id} />}
</div>
<div className="p-1">
<Menu.Item>
{({ active }) => (
<ForwardedLink
href={`/profile`}
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
active
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
: ''
}`}
data-testid="user-menu-profile"
>
<UserIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.myprofile)}</span>
</ForwardedLink>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<ForwardedLink
href={`/users/${user?.id}/requests?filter=all`}
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
active
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
: ''
}`}
data-testid="user-menu-settings"
>
<ClockIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.requests)}</span>
</ForwardedLink>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<ForwardedLink
href={`/profile/settings`}
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
active
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
: ''
}`}
data-testid="user-menu-settings"
>
<CogIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.settings)}</span>
</ForwardedLink>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href="#"
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
active
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
: ''
}`}
onClick={() => logout()}
>
<LogoutIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.signout)}</span>
</a>
)}
</Menu.Item>
</div>
</div>
</div>
</Menu.Items>
</Transition>
</div>
</Menu>
);
};

View File

@@ -1,8 +1,8 @@
import React from 'react';
import Link from 'next/link';
import { useUser } from '@app/hooks/useUser';
import { ExclamationIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useUser } from '../../../hooks/useUser';
const messages = defineMessages({
emailRequired: 'An email address is required.',

View File

@@ -4,11 +4,10 @@ import {
CodeIcon,
ServerIcon,
} from '@heroicons/react/outline';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
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',
@@ -22,7 +21,7 @@ interface VersionStatusProps {
onClick?: () => void;
}
const VersionStatus: React.FC<VersionStatusProps> = ({ onClick }) => {
const VersionStatus = ({ onClick }: VersionStatusProps) => {
const intl = useIntl();
const { data } = useSWR<StatusResponse>('/api/v1/status', {
refreshInterval: 60 * 1000,

View File

@@ -1,16 +1,20 @@
import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown';
import type { AvailableLocale } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { MenuAlt2Icon } from '@heroicons/react/outline';
import { ArrowLeftIcon } from '@heroicons/react/solid';
import { useRouter } from 'next/router';
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';
import { useEffect, useState } from 'react';
const Layout: React.FC = ({ children }) => {
type LayoutProps = {
children: React.ReactNode;
};
const Layout = ({ children }: LayoutProps) => {
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const { user } = useUser();
@@ -69,6 +73,7 @@ const Layout: React.FC = ({ children }) => {
} transition duration-300 focus:outline-none lg:hidden`}
aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)}
data-testid="sidebar-toggle"
>
<MenuAlt2Icon className="h-6 w-6" />
</button>

View File

@@ -1,7 +1,7 @@
import { NProgress } from '@tanem/react-nprogress';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useRouter } from 'next/router';
interface BarProps {
progress: number;

View File

@@ -1,11 +1,11 @@
import React from 'react';
import Transition from '../Transition';
import Modal from '../Common/Modal';
import { Formik, Field } from 'formik';
import * as Yup from 'yup';
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import { Transition } from '@headlessui/react';
import axios from 'axios';
import { Field, Formik } from 'formik';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSettings from '../../hooks/useSettings';
import * as Yup from 'yup';
const messages = defineMessages({
title: 'Add Email',

View File

@@ -1,12 +1,12 @@
import Button from '@app/components/Common/Button';
import useSettings from '@app/hooks/useSettings';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import React from 'react';
import getConfig from 'next/config';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import useSettings from '../../hooks/useSettings';
import Button from '../Common/Button';
import getConfig from 'next/config';
const messages = defineMessages({
username: 'Username',

View File

@@ -1,13 +1,13 @@
import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
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 { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup';
import useSettings from '../../hooks/useSettings';
import Button from '../Common/Button';
import SensitiveInput from '../Common/SensitiveInput';
const messages = defineMessages({
username: 'Username',
@@ -25,7 +25,7 @@ interface LocalLoginProps {
revalidate: () => void;
}
const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
const LocalLogin = ({ revalidate }: LocalLoginProps) => {
const intl = useIntl();
const settings = useSettings();
const [loginError, setLoginError] = useState<string | null>(null);
@@ -80,11 +80,14 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
name="email"
type="text"
inputMode="email"
data-testid="email"
/>
</div>
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
{errors.email &&
touched.email &&
typeof errors.email === 'string' && (
<div className="error">{errors.email}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
@@ -97,11 +100,14 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
name="password"
type="password"
autoComplete="current-password"
data-testid="password"
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
{errors.password &&
touched.password &&
typeof errors.password === 'string' && (
<div className="error">{errors.password}</div>
)}
</div>
{loginError && (
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
@@ -116,6 +122,7 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
data-testid="local-signin-button"
>
<LoginIcon />
<span>

View File

@@ -1,21 +1,21 @@
import Accordion from '@app/components/Common/Accordion';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import LocalLogin from '@app/components/Login/LocalLogin';
import PlexLoginButton from '@app/components/PlexLoginButton';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/solid';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import getConfig from 'next/config';
import { useRouter } from 'next/dist/client/router';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
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';
import getConfig from 'next/config';
const messages = defineMessages({
signin: 'Sign In',
@@ -25,7 +25,7 @@ const messages = defineMessages({
signinwithoverseerr: 'Use your {applicationTitle} account',
});
const Login: React.FC = () => {
const Login = () => {
const intl = useIntl();
const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false);
@@ -78,7 +78,7 @@ const Login: React.FC = () => {
<ImageFader
backgroundImages={
backdrops?.map(
(backdrop) => `https://www.themoviedb.org/t/p/original${backdrop}`
(backdrop) => `https://image.tmdb.org/t/p/original${backdrop}`
) ?? []
}
/>
@@ -98,6 +98,7 @@ const Login: React.FC = () => {
>
<>
<Transition
as="div"
show={!!error}
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"

View File

@@ -1,27 +1,23 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import DownloadBlock from '@app/components/DownloadBlock';
import IssueBlock from '@app/components/IssueBlock';
import RequestBlock from '@app/components/RequestBlock';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { ServerIcon, ViewListIcon } from '@heroicons/react/outline';
import { CheckCircleIcon, DocumentRemoveIcon } from '@heroicons/react/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { IssueStatus } from '../../../server/constants/issue';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import { MediaWatchDataResponse } from '../../../server/interfaces/api/mediaInterfaces';
import { MovieDetails } from '../../../server/models/Movie';
import { TvDetails } from '../../../server/models/Tv';
import useSettings from '../../hooks/useSettings';
import { Permission, useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import ConfirmButton from '../Common/ConfirmButton';
import SlideOver from '../Common/SlideOver';
import DownloadBlock from '../DownloadBlock';
import IssueBlock from '../IssueBlock';
import RequestBlock from '../RequestBlock';
const messages = defineMessages({
manageModalTitle: 'Manage {mediaType}',
@@ -72,9 +68,13 @@ interface ManageSlideOverTvProps extends ManageSlideOverProps {
data: TvDetails;
}
const ManageSlideOver: React.FC<
ManageSlideOverMovieProps | ManageSlideOverTvProps
> = ({ show, mediaType, onClose, data, revalidate }) => {
const ManageSlideOver = ({
show,
mediaType,
onClose,
data,
revalidate,
}: ManageSlideOverMovieProps | ManageSlideOverTvProps) => {
const { user: currentUser, hasPermission } = useUser();
const intl = useIntl();
const settings = useSettings();
@@ -115,9 +115,9 @@ const ManageSlideOver: React.FC<
<>
{intl.formatMessage(messages.plays, {
playCount,
strong: function strong(msg) {
return <strong className="text-2xl font-semibold">{msg}</strong>;
},
strong: (msg: React.ReactNode) => (
<strong className="text-2xl font-semibold">{msg}</strong>
),
})}
</>
);
@@ -141,7 +141,7 @@ const ManageSlideOver: React.FC<
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.downloadstatus)}
</h3>
<div className="overflow-hidden rounded-md bg-gray-600 shadow">
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<li
@@ -167,11 +167,11 @@ const ManageSlideOver: React.FC<
type: 'or',
}) &&
openIssues.length > 0 && (
<>
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalIssues)}
</h3>
<div className="overflow-hidden rounded-md bg-gray-600 shadow">
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{openIssues.map((issue) => (
<li
@@ -183,14 +183,14 @@ const ManageSlideOver: React.FC<
))}
</ul>
</div>
</>
</div>
)}
{requests.length > 0 && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalRequests)}
</h3>
<div className="overflow-hidden rounded-md bg-gray-600 shadow">
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{requests.map((request) => (
<li
@@ -210,77 +210,81 @@ const ManageSlideOver: React.FC<
{hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl ||
data.mediaInfo?.tautulliUrl ||
!!watchData?.data?.playCount) && (
watchData?.data) && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalMedia)}
</h3>
<div className="space-y-2">
{!!watchData?.data && (
{(watchData?.data || data.mediaInfo?.tautulliUrl) && (
<div>
<div
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden bg-gray-600 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl
? 'rounded-t-md'
: 'rounded-md'
}`}
>
<div className="grid grid-cols-3 divide-x divide-gray-500">
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, { days: 7 })}
{!!watchData?.data && (
<div
className={`grid grid-cols-1 divide-y divide-gray-700 overflow-hidden border-gray-700 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl
? 'rounded-t-md border-x border-t'
: 'rounded-md border'
}`}
>
<div className="grid grid-cols-3 divide-x divide-gray-700">
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 7,
})}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount7Days)}
</div>
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount7Days)}
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 30,
})}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount30Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.alltime)}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount)}
</div>
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 30,
})}
{!!watchData.data.users.length && (
<div className="flex flex-row space-x-2 px-4 pt-3 pb-2">
<span className="shrink-0 font-bold leading-8">
{intl.formatMessage(messages.playedby)}
</span>
<span className="flex flex-row flex-wrap">
{watchData.data.users.map((user) => (
<Link
href={
currentUser?.id === user.id
? '/profile'
: `/users/${user.id}`
}
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
))}
</span>
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount30Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.alltime)}
</div>
<div className="text-white">
{styledPlayCount(watchData.data.playCount)}
</div>
</div>
)}
</div>
{!!watchData.data.users.length && (
<div className="flex flex-row space-x-2 px-4 pt-3 pb-2">
<span className="shrink-0 font-bold leading-8">
{intl.formatMessage(messages.playedby)}
</span>
<span className="flex flex-row flex-wrap">
{watchData.data.users.map((user) => (
<Link
href={
currentUser?.id === user.id
? '/profile'
: `/users/${user.id}`
}
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
))}
</span>
</div>
)}
</div>
)}
{data.mediaInfo?.tautulliUrl && (
<a
href={data.mediaInfo.tautulliUrl}
@@ -290,7 +294,7 @@ const ManageSlideOver: React.FC<
<Button
buttonType="ghost"
className={`w-full ${
watchData.data.playCount ? 'rounded-t-none' : ''
watchData?.data ? 'rounded-t-none' : ''
}`}
>
<ViewListIcon />
@@ -302,7 +306,7 @@ const ManageSlideOver: React.FC<
)}
</div>
)}
{data?.mediaInfo?.serviceUrl && (
{data.mediaInfo?.serviceUrl && (
<a
href={data?.mediaInfo?.serviceUrl}
target="_blank"
@@ -325,77 +329,83 @@ const ManageSlideOver: React.FC<
{hasPermission(Permission.ADMIN) &&
(data.mediaInfo?.serviceUrl4k ||
data.mediaInfo?.tautulliUrl4k ||
!!watchData?.data4k?.playCount) && (
watchData?.data4k) && (
<div>
<h3 className="mb-2 text-xl font-bold">
{intl.formatMessage(messages.manageModalMedia4k)}
</h3>
<div className="space-y-2">
{!!watchData?.data4k && (
{(watchData?.data4k || data.mediaInfo?.tautulliUrl4k) && (
<div>
<div
className={`grid grid-cols-1 divide-y divide-gray-500 overflow-hidden bg-gray-600 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl4k
? 'rounded-t-md'
: 'rounded-md'
}`}
>
<div className="grid grid-cols-3 divide-x divide-gray-500">
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, { days: 7 })}
{watchData?.data4k && (
<div
className={`grid grid-cols-1 divide-y divide-gray-700 overflow-hidden border-gray-700 text-sm text-gray-300 shadow ${
data.mediaInfo?.tautulliUrl4k
? 'rounded-t-md border-x border-t'
: 'rounded-md border'
}`}
>
<div className="grid grid-cols-3 divide-x divide-gray-700">
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 7,
})}
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount7Days)}
</div>
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount7Days)}
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 30,
})}
</div>
<div className="text-white">
{styledPlayCount(
watchData.data4k.playCount30Days
)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.alltime)}
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount)}
</div>
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.pastdays, {
days: 30,
})}
{!!watchData.data4k.users.length && (
<div className="flex flex-row space-x-2 px-4 pt-3 pb-2">
<span className="shrink-0 font-bold leading-8">
{intl.formatMessage(messages.playedby)}
</span>
<span className="flex flex-row flex-wrap">
{watchData.data4k.users.map((user) => (
<Link
href={
currentUser?.id === user.id
? '/profile'
: `/users/${user.id}`
}
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
))}
</span>
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount30Days)}
</div>
</div>
<div className="px-4 py-3">
<div className="font-bold">
{intl.formatMessage(messages.alltime)}
</div>
<div className="text-white">
{styledPlayCount(watchData.data4k.playCount)}
</div>
</div>
)}
</div>
{!!watchData.data4k.users.length && (
<div className="flex flex-row space-x-2 px-4 pt-3 pb-2">
<span className="shrink-0 font-bold leading-8">
{intl.formatMessage(messages.playedby)}
</span>
<span className="flex flex-row flex-wrap">
{watchData.data4k.users.map((user) => (
<Link
href={
currentUser?.id === user.id
? '/profile'
: `/users/${user.id}`
}
key={`watch-user-${user.id}`}
>
<a className="z-0 mb-1 -mr-2 shrink-0 hover:z-50">
<img
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
/>
</a>
</Link>
))}
</span>
</div>
)}
</div>
)}
{data.mediaInfo?.tautulliUrl4k && (
<a
href={data.mediaInfo.tautulliUrl4k}
@@ -405,7 +415,7 @@ const ManageSlideOver: React.FC<
<Button
buttonType="ghost"
className={`w-full ${
watchData.data4k.playCount ? 'rounded-t-none' : ''
watchData?.data4k ? 'rounded-t-none' : ''
}`}
>
<ViewListIcon />
@@ -487,7 +497,7 @@ const ManageSlideOver: React.FC<
{intl.formatMessage(messages.manageModalClearMedia)}
</span>
</ConfirmButton>
<div className="mt-1 text-xs text-gray-400">
<div className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.manageModalClearMediaWarning, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? messages.movie : messages.tvshow

View File

@@ -1,6 +1,6 @@
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
import Link from 'next/link';
import React, { useState } from 'react';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
@@ -12,7 +12,7 @@ interface ShowMoreCardProps {
posters: (string | undefined)[];
}
const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
const intl = useIntl();
const [isHovered, setHovered] = useState(false);
return (

View File

@@ -1,18 +1,18 @@
import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard';
import PersonCard from '@app/components/PersonCard';
import Slider from '@app/components/Slider';
import TitleCard from '@app/components/TitleCard';
import useSettings from '@app/hooks/useSettings';
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
import Link from 'next/link';
import React, { useEffect } from 'react';
import useSWRInfinite from 'swr/infinite';
import { MediaStatus } from '../../../server/constants/media';
import { MediaStatus } from '@server/constants/media';
import type {
MovieResult,
PersonResult,
TvResult,
} from '../../../server/models/Search';
import useSettings from '../../hooks/useSettings';
import PersonCard from '../PersonCard';
import Slider from '../Slider';
import TitleCard from '../TitleCard';
import ShowMoreCard from './ShowMoreCard';
} from '@server/models/Search';
import Link from 'next/link';
import { useEffect } from 'react';
import useSWRInfinite from 'swr/infinite';
interface MixedResult {
page: number;
@@ -29,13 +29,13 @@ interface MediaSliderProps {
hideWhenEmpty?: boolean;
}
const MediaSlider: React.FC<MediaSliderProps> = ({
const MediaSlider = ({
title,
url,
linkUrl,
sliderKey,
hideWhenEmpty = false,
}) => {
}: MediaSliderProps) => {
const settings = useSettings();
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
(pageIndex: number, previousPageData: MixedResult | null) => {

View File

@@ -1,20 +1,19 @@
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import PersonCard from '@app/components/PersonCard';
import Error from '@app/pages/_error';
import type { MovieDetails } from '@server/models/Movie';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullcast: 'Full Cast',
});
const MovieCast: React.FC = () => {
const MovieCast = () => {
const router = useRouter();
const intl = useIntl();
const { data, error } = useSWR<MovieDetails>(

View File

@@ -1,20 +1,19 @@
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import PersonCard from '@app/components/PersonCard';
import Error from '@app/pages/_error';
import type { MovieDetails } from '@server/models/Movie';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MovieDetails } from '../../../../server/models/Movie';
import Error from '../../../pages/_error';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import PageTitle from '../../Common/PageTitle';
import PersonCard from '../../PersonCard';
const messages = defineMessages({
fullcrew: 'Full Crew',
});
const MovieCrew: React.FC = () => {
const MovieCrew = () => {
const router = useRouter();
const intl = useIntl();
const { data, error } = useSWR<MovieDetails>(

View File

@@ -1,21 +1,20 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { MovieDetails } from '@server/models/Movie';
import type { MovieResult } from '@server/models/Search';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
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',
});
const MovieRecommendations: React.FC = () => {
const MovieRecommendations = () => {
const intl = useIntl();
const router = useRouter();
const { data: movieData } = useSWR<MovieDetails>(

View File

@@ -1,21 +1,20 @@
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type { MovieDetails } from '@server/models/Movie';
import type { MovieResult } from '@server/models/Search';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MovieDetails } from '../../../server/models/Movie';
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',
});
const MovieSimilar: React.FC = () => {
const MovieSimilar = () => {
const router = useRouter();
const intl = useIntl();
const { data: movieData } = useSWR<MovieDetails>(

View File

@@ -1,3 +1,29 @@
import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import Tooltip from '@app/components/Common/Tooltip';
import ExternalLinkBlock from '@app/components/ExternalLinkBlock';
import IssueModal from '@app/components/IssueModal';
import ManageSlideOver from '@app/components/ManageSlideOver';
import MediaSlider from '@app/components/MediaSlider';
import PersonCard from '@app/components/PersonCard';
import RequestButton from '@app/components/RequestButton';
import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import {
ArrowCircleRightIcon,
CloudIcon,
@@ -11,44 +37,20 @@ import {
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
} from '@heroicons/react/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import { IssueStatus } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import { hasFlag } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
import { uniqBy } from 'lodash';
import getConfig from 'next/config';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
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 LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import PlayButton, { PlayButtonLink } from '../Common/PlayButton';
import ExternalLinkBlock from '../ExternalLinkBlock';
import IssueModal from '../IssueModal';
import ManageSlideOver from '../ManageSlideOver';
import MediaSlider from '../MediaSlider';
import PersonCard from '../PersonCard';
import RequestButton from '../RequestButton';
import Slider from '../Slider';
import StatusBadge from '../StatusBadge';
import getConfig from 'next/config';
const messages = defineMessages({
originaltitle: 'Original Title',
@@ -78,13 +80,21 @@ const messages = defineMessages({
streamingproviders: 'Currently Streaming On',
productioncountries:
'Production {countryCount, plural, one {Country} other {Countries}}',
theatricalrelease: 'Theatrical Release',
digitalrelease: 'Digital Release',
physicalrelease: 'Physical Release',
reportissue: 'Report an Issue',
managemovie: 'Manage Movie',
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score',
});
interface MovieDetailsProps {
movie?: MovieDetailsType;
}
const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const MovieDetails = ({ movie }: MovieDetailsProps) => {
const settings = useSettings();
const { user, hasPermission } = useUser();
const router = useRouter();
@@ -119,6 +129,32 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
setShowManager(router.query.manage == '1' ? true : false);
}, [router.query.manage]);
const [plexUrl, setPlexUrl] = useState(data?.mediaInfo?.mediaUrl);
const [plexUrl4k, setPlexUrl4k] = useState(data?.mediaInfo?.mediaUrl4k);
useEffect(() => {
if (data) {
if (
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
) {
setPlexUrl(data.mediaInfo?.iOSPlexUrl);
setPlexUrl4k(data.mediaInfo?.iOSPlexUrl4k);
} else {
setPlexUrl(data.mediaInfo?.mediaUrl);
setPlexUrl4k(data.mediaInfo?.mediaUrl4k);
}
}
}, [
data,
data?.mediaInfo?.iOSPlexUrl,
data?.mediaInfo?.iOSPlexUrl4k,
data?.mediaInfo?.mediaUrl,
data?.mediaInfo?.mediaUrl4k,
settings.currentSettings.mediaServerType,
]);
if (!data && !error) {
return <LoadingSpinner />;
}
@@ -130,27 +166,32 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
const mediaLinks: PlayButtonLink[] = [];
if (data.mediaInfo?.mediaUrl) {
if (
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({
text: getAvalaibleMediaServerName(),
url: data.mediaInfo?.mediaUrl,
url: plexUrl,
svg: <PlayIcon />,
});
}
if (
data.mediaInfo?.mediaUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
settings.currentSettings.movie4kEnabled &&
plexUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({
text: getAvalaible4kMediaServerName(),
url: data.mediaInfo?.mediaUrl4k,
url: plexUrl4k,
svg: <PlayIcon />,
});
}
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
@@ -315,7 +356,8 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.mediaUrl}
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
@@ -336,11 +378,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.mediaUrl4k}
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl4k}
/>
)}
</div>
<h1>
<h1 data-testid="media-title">
{data.title}{' '}
{data.releaseDate && (
<span className="media-year">
@@ -384,38 +427,42 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
type: 'or',
}
) && (
<Button
buttonType="warning"
className="ml-2 first:ml-0"
onClick={() => setShowIssueModal(true)}
>
<ExclamationIcon />
</Button>
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationIcon />
</Button>
</Tooltip>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && data.mediaInfo && (
<Button
buttonType="default"
className="relative ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
<Tooltip content={intl.formatMessage(messages.managemovie)}>
<Button
buttonType="ghost"
onClick={() => setShowManager(true)}
className="relative ml-2 first:ml-0"
>
<CogIcon className="!mr-0" />
{hasPermission(
[Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES],
{
type: 'or',
}
) &&
(
data.mediaInfo?.issues.filter(
(issue) => issue.status === IssueStatus.OPEN
) ?? []
).length > 0 && (
<>
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-red-600" />
<div className="absolute -right-1 -top-1 h-3 w-3 animate-ping rounded-full bg-red-600" />
</>
)}
</Button>
</Tooltip>
)}
</div>
</div>
@@ -489,36 +536,55 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
<div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
<>
<span className="media-rating">
<Tooltip
content={intl.formatMessage(messages.rtcriticsscore)}
>
<a
href={ratingData.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="mr-1 w-6" />
<RTRotten className="w-6" />
) : (
<RTFresh className="mr-1 w-6" />
<RTFresh className="w-6" />
)}
{ratingData.criticsScore}%
</span>
</>
<span>{ratingData.criticsScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<>
<span className="media-rating">
<Tooltip
content={intl.formatMessage(messages.rtaudiencescore)}
>
<a
href={ratingData.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="mr-1 w-6" />
<RTAudRotten className="w-6" />
) : (
<RTAudFresh className="mr-1 w-6" />
<RTAudFresh className="w-6" />
)}
{ratingData.audienceScore}%
</span>
</>
<span>{ratingData.audienceScore}%</span>
</a>
</Tooltip>
)}
{!!data.voteCount && (
<>
<span className="media-rating">
<TmdbLogo className="mr-2 w-6" />
{data.voteAverage}/10
</span>
</>
<Tooltip content={intl.formatMessage(messages.tmdbuserscore)}>
<a
href={`https://www.themoviedb.org/movie/${data.id}?language=${locale}`}
className="media-rating"
target="_blank"
rel="noreferrer"
>
<TmdbLogo className="mr-1 w-6" />
<span>{Math.round(data.voteAverage * 10)}%</span>
</a>
</Tooltip>
)}
</div>
)}
@@ -548,22 +614,36 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
>
{r.type === 3 ? (
// Theatrical
<TicketIcon className="h-4 w-4" />
<Tooltip
content={intl.formatMessage(
messages.theatricalrelease
)}
>
<TicketIcon className="h-4 w-4" />
</Tooltip>
) : r.type === 4 ? (
// Digital
<CloudIcon className="h-4 w-4" />
<Tooltip
content={intl.formatMessage(messages.digitalrelease)}
>
<CloudIcon className="h-4 w-4" />
</Tooltip>
) : (
// Physical
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<Tooltip
content={intl.formatMessage(messages.physicalrelease)}
>
<path
d="m12 2c-5.5242 0-10 4.4758-10 10 0 5.5242 4.4758 10 10 10 5.5242 0 10-4.4758 10-10 0-5.5242-4.4758-10-10-10zm0 18.065c-4.4476 0-8.0645-3.6169-8.0645-8.0645 0-4.4476 3.6169-8.0645 8.0645-8.0645 4.4476 0 8.0645 3.6169 8.0645 8.0645 0 4.4476-3.6169 8.0645-8.0645 8.0645zm0-14.516c-3.5565 0-6.4516 2.8952-6.4516 6.4516h1.2903c0-2.8468 2.3145-5.1613 5.1613-5.1613zm0 2.9032c-1.9597 0-3.5484 1.5887-3.5484 3.5484s1.5887 3.5484 3.5484 3.5484 3.5484-1.5887 3.5484-3.5484-1.5887-3.5484-3.5484-3.5484zm0 4.8387c-0.71371 0-1.2903-0.57661-1.2903-1.2903s0.57661-1.2903 1.2903-1.2903 1.2903 0.57661 1.2903 1.2903-0.57661 1.2903-1.2903 1.2903z"
fill="currentColor"
/>
</svg>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m12 2c-5.5242 0-10 4.4758-10 10 0 5.5242 4.4758 10 10 10 5.5242 0 10-4.4758 10-10 0-5.5242-4.4758-10-10-10zm0 18.065c-4.4476 0-8.0645-3.6169-8.0645-8.0645 0-4.4476 3.6169-8.0645 8.0645-8.0645 4.4476 0 8.0645 3.6169 8.0645 8.0645 0 4.4476-3.6169 8.0645-8.0645 8.0645zm0-14.516c-3.5565 0-6.4516 2.8952-6.4516 6.4516h1.2903c0-2.8468 2.3145-5.1613 5.1613-5.1613zm0 2.9032c-1.9597 0-3.5484 1.5887-3.5484 3.5484s1.5887 3.5484 3.5484 3.5484 3.5484-1.5887 3.5484-3.5484-1.5887-3.5484-3.5484-3.5484zm0 4.8387c-0.71371 0-1.2903-0.57661-1.2903-1.2903s0.57661-1.2903 1.2903-1.2903 1.2903 0.57661 1.2903 1.2903-0.57661 1.2903-1.2903 1.2903z"
fill="currentColor"
/>
</svg>
</Tooltip>
)}
<span className="ml-1.5">
{intl.formatDate(r.release_date, {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { hasNotificationType, NotificationItem } from '..';
import type { NotificationItem } from '@app/components/NotificationTypeSelector';
import { hasNotificationType } from '@app/components/NotificationTypeSelector';
interface NotificationTypeProps {
option: NotificationItem;
@@ -8,12 +8,12 @@ interface NotificationTypeProps {
onUpdate: (newTypes: number) => void;
}
const NotificationType: React.FC<NotificationTypeProps> = ({
const NotificationType = ({
option,
currentTypes,
onUpdate,
parent,
}) => {
}: NotificationTypeProps) => {
return (
<>
<div

View File

@@ -1,9 +1,10 @@
import NotificationType from '@app/components/NotificationTypeSelector/NotificationType';
import useSettings from '@app/hooks/useSettings';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import { sortBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { 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',
@@ -59,6 +60,9 @@ const messages = defineMessages({
'Get notified when issues you reported are reopened.',
adminissuereopenedDescription:
'Get notified when issues are reopened by other users.',
mediaautorequested: 'Request Automatically Submitted',
mediaautorequestedDescription:
'Get notified when new media requests are automatically submitted for items on your Plex Watchlist.',
});
export const hasNotificationType = (
@@ -100,6 +104,7 @@ export enum Notification {
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
MEDIA_AUTO_REQUESTED = 4096,
}
export const ALL_NOTIFICATIONS = Object.values(Notification)
@@ -124,13 +129,13 @@ interface NotificationTypeSelectorProps {
error?: string;
}
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
const NotificationTypeSelector = ({
user,
enabledTypes = ALL_NOTIFICATIONS,
currentTypes,
onUpdate,
error,
}) => {
}: NotificationTypeSelectorProps) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser({ id: user?.id });
@@ -190,6 +195,25 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
))));
const types: NotificationItem[] = [
{
id: 'media-auto-requested',
name: intl.formatMessage(messages.mediaautorequested),
description: intl.formatMessage(messages.mediaautorequestedDescription),
value: Notification.MEDIA_AUTO_REQUESTED,
hidden:
!user ||
(!user.settings?.watchlistSyncMovies &&
!user.settings?.watchlistSyncTv) ||
!hasPermission(
[
Permission.AUTO_REQUEST,
Permission.AUTO_REQUEST_MOVIE,
Permission.AUTO_REQUEST_TV,
],
{ type: 'or' }
),
hasNotifyUser: true,
},
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),

View File

@@ -1,12 +1,8 @@
import React from 'react';
interface PWAHeaderProps {
applicationTitle?: string;
}
const PWAHeader: React.FC<PWAHeaderProps> = ({
applicationTitle = 'Overseerr',
}) => {
const PWAHeader = ({ applicationTitle = 'Overseerr' }: PWAHeaderProps) => {
return (
<>
<link

View File

@@ -1,7 +1,8 @@
import React from 'react';
import type { PermissionItem } from '@app/components/PermissionOption';
import PermissionOption from '@app/components/PermissionOption';
import type { User } from '@app/hooks/useUser';
import { Permission } from '@app/hooks/useUser';
import { defineMessages, useIntl } from 'react-intl';
import { Permission, User } from '../../hooks/useUser';
import PermissionOption, { PermissionItem } from '../PermissionOption';
export const messages = defineMessages({
admin: 'Admin',
@@ -10,9 +11,6 @@ export const messages = defineMessages({
users: 'Manage Users',
usersDescription:
'Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.',
settings: 'Manage Settings',
settingsDescription:
'Grant permission to modify global settings. A user must have this permission to grant it to others.',
managerequests: 'Manage Requests',
managerequestsDescription:
'Grant permission to manage media requests. All requests made by a user with this permission will be automatically approved.',
@@ -52,6 +50,15 @@ export const messages = defineMessages({
advancedrequest: 'Advanced Requests',
advancedrequestDescription:
'Grant permission to modify advanced media request options.',
autorequest: 'Auto-Request',
autorequestDescription:
'Grant permission to automatically submit requests for non-4K media via Plex Watchlist.',
autorequestMovies: 'Auto-Request Movies',
autorequestMoviesDescription:
'Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.',
autorequestSeries: 'Auto-Request Series',
autorequestSeriesDescription:
'Grant permission to automatically submit requests for non-4K series via Plex Watchlist.',
viewrequests: 'View Requests',
viewrequestsDescription:
'Grant permission to view media requests submitted by other users.',
@@ -62,6 +69,12 @@ export const messages = defineMessages({
viewissues: 'View Issues',
viewissuesDescription:
'Grant permission to view media issues reported by other users.',
viewrecent: 'View Recently Added',
viewrecentDescription:
'Grant permission to view the list of recently added media.',
viewwatchlists: 'View Plex Watchlists',
viewwatchlistsDescription:
"Grant permission to view other users' Plex Watchlists.",
});
interface PermissionEditProps {
@@ -71,12 +84,12 @@ interface PermissionEditProps {
onUpdate: (newPermissions: number) => void;
}
export const PermissionEdit: React.FC<PermissionEditProps> = ({
export const PermissionEdit = ({
actingUser,
currentUser,
currentPermission,
onUpdate,
}) => {
}: PermissionEditProps) => {
const intl = useIntl();
const permissionList: PermissionItem[] = [
@@ -86,12 +99,6 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
description: intl.formatMessage(messages.adminDescription),
permission: Permission.ADMIN,
},
{
id: 'settings',
name: intl.formatMessage(messages.settings),
description: intl.formatMessage(messages.settingsDescription),
permission: Permission.MANAGE_SETTINGS,
},
{
id: 'users',
name: intl.formatMessage(messages.users),
@@ -116,6 +123,18 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
description: intl.formatMessage(messages.viewrequestsDescription),
permission: Permission.REQUEST_VIEW,
},
{
id: 'viewrecent',
name: intl.formatMessage(messages.viewrecent),
description: intl.formatMessage(messages.viewrecentDescription),
permission: Permission.RECENT_VIEW,
},
{
id: 'viewwatchlists',
name: intl.formatMessage(messages.viewwatchlists),
description: intl.formatMessage(messages.viewwatchlistsDescription),
permission: Permission.WATCHLIST_VIEW,
},
],
},
{
@@ -175,6 +194,43 @@ export const PermissionEdit: React.FC<PermissionEditProps> = ({
},
],
},
{
id: 'autorequest',
name: intl.formatMessage(messages.autorequest),
description: intl.formatMessage(messages.autorequestDescription),
permission: Permission.AUTO_REQUEST,
requires: [{ permissions: [Permission.REQUEST] }],
children: [
{
id: 'autorequestmovies',
name: intl.formatMessage(messages.autorequestMovies),
description: intl.formatMessage(
messages.autorequestMoviesDescription
),
permission: Permission.AUTO_REQUEST_MOVIE,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_MOVIE],
type: 'or',
},
],
},
{
id: 'autorequesttv',
name: intl.formatMessage(messages.autorequestSeries),
description: intl.formatMessage(
messages.autorequestSeriesDescription
),
permission: Permission.AUTO_REQUEST_TV,
requires: [
{
permissions: [Permission.REQUEST, Permission.REQUEST_TV],
type: 'or',
},
],
},
],
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { hasPermission } from '../../../server/lib/permissions';
import useSettings from '../../hooks/useSettings';
import { Permission, User } from '../../hooks/useUser';
import useSettings from '@app/hooks/useSettings';
import type { User } from '@app/hooks/useUser';
import { Permission } from '@app/hooks/useUser';
import { hasPermission } from '@server/lib/permissions';
export interface PermissionItem {
id: string;
@@ -26,14 +26,14 @@ interface PermissionOptionProps {
onUpdate: (newPermissions: number) => void;
}
const PermissionOption: React.FC<PermissionOptionProps> = ({
const PermissionOption = ({
option,
actingUser,
currentUser,
currentPermission,
onUpdate,
parent,
}) => {
}: PermissionOptionProps) => {
const settings = useSettings();
const autoApprovePermissions = [
@@ -66,14 +66,9 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
}
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)
// Only the owner can modify the Admin permission
actingUser?.id !== 1 &&
option.permission === Permission.ADMIN
) {
disabled = true;
}

View File

@@ -1,7 +1,7 @@
import CachedImage from '@app/components/Common/CachedImage';
import { UserCircleIcon } from '@heroicons/react/solid';
import Link from 'next/link';
import React, { useState } from 'react';
import CachedImage from '../Common/CachedImage';
import { useState } from 'react';
interface PersonCardProps {
personId: number;
@@ -11,13 +11,13 @@ interface PersonCardProps {
canExpand?: boolean;
}
const PersonCard: React.FC<PersonCardProps> = ({
const PersonCard = ({
personId,
name,
subName,
profilePath,
canExpand = false,
}) => {
}: PersonCardProps) => {
const [isHovered, setHovered] = useState(false);
return (

View File

@@ -1,19 +1,19 @@
import Ellipsis from '@app/assets/ellipsis.svg';
import CachedImage from '@app/components/Common/CachedImage';
import ImageFader from '@app/components/Common/ImageFader';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import TitleCard from '@app/components/TitleCard';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { PersonCombinedCreditsResponse } from '@server/interfaces/api/personInterfaces';
import type { PersonDetails as PersonDetailsType } from '@server/models/Person';
import { groupBy } from 'lodash';
import { useRouter } from 'next/router';
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import TruncateMarkup from 'react-truncate-markup';
import useSWR from 'swr';
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
import type { PersonDetails as PersonDetailsType } from '../../../server/models/Person';
import Ellipsis from '../../assets/ellipsis.svg';
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({
birthdate: 'Born {birthdate}',
@@ -24,7 +24,7 @@ const messages = defineMessages({
ascharacter: 'as {character}',
});
const PersonDetails: React.FC = () => {
const PersonDetails = () => {
const intl = useIntl();
const router = useRouter();
const { data, error } = useSWR<PersonDetailsType>(

View File

@@ -1,8 +1,8 @@
import globalMessages from '@app/i18n/globalMessages';
import PlexOAuth from '@app/utils/plex';
import { LoginIcon } from '@heroicons/react/outline';
import React, { useState } from 'react';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
import PlexOAuth from '../../utils/plex';
const messages = defineMessages({
signinwithplex: 'Sign In',
@@ -17,11 +17,11 @@ interface PlexLoginButtonProps {
onError?: (message: string) => void;
}
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
const PlexLoginButton = ({
onAuthToken,
onError,
isProcessing,
}) => {
}: PlexLoginButtonProps) => {
const intl = useIntl();
const [loading, setLoading] = useState(false);

View File

@@ -24,7 +24,7 @@ interface QuotaSelectorProps {
onChange: (fieldName: string, value: number) => void;
}
const QuotaSelector: React.FC<QuotaSelectorProps> = ({
const QuotaSelector = ({
mediaType,
dayFieldName,
limitFieldName,
@@ -34,7 +34,7 @@ const QuotaSelector: React.FC<QuotaSelectorProps> = ({
limitOverride,
isDisabled = false,
onChange,
}) => {
}: QuotaSelectorProps) => {
const initialDays = defaultDays ?? 7;
const initialLimit = defaultLimit ?? 0;
const [quotaDays, setQuotaDays] = useState(initialDays);

View File

@@ -1,13 +1,13 @@
import useSettings from '@app/hooks/useSettings';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import type { Region } from '@server/lib/settings';
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 { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { Region } from '../../../server/lib/settings';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
regionDefault: 'All Regions',
@@ -21,12 +21,12 @@ interface RegionSelectorProps {
onChange?: (fieldName: string, region: string) => void;
}
const RegionSelector: React.FC<RegionSelectorProps> = ({
const RegionSelector = ({
name,
value,
isUserSetting = false,
onChange,
}) => {
}: RegionSelectorProps) => {
const { currentSettings } = useSettings();
const intl = useIntl();
const { data: regions } = useSWR<Region[]>('/api/v1/regions');

View File

@@ -1,3 +1,10 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import useRequestOverride from '@app/hooks/useRequestOverride';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import {
CalendarIcon,
CheckIcon,
@@ -7,18 +14,12 @@ import {
UserIcon,
XIcon,
} from '@heroicons/react/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import Link from 'next/link';
import React, { useState } from 'react';
import { 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 { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import RequestModal from '../RequestModal';
const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
@@ -27,6 +28,13 @@ const messages = defineMessages({
profilechanged: 'Quality Profile',
rootfolder: 'Root Folder',
languageprofile: 'Language Profile',
requestdate: 'Request Date',
requestedby: 'Requested By',
lastmodifiedby: 'Last Modified By',
approve: 'Approve Request',
decline: 'Decline Request',
edit: 'Edit Request',
delete: 'Delete Request',
});
interface RequestBlockProps {
@@ -34,7 +42,7 @@ interface RequestBlockProps {
onUpdate?: () => void;
}
const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
const { user } = useUser();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
@@ -83,7 +91,9 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
<Tooltip content={intl.formatMessage(messages.requestedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
@@ -100,7 +110,9 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
{request.modifiedBy && (
<div className="flex flex-nowrap">
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<Tooltip content={intl.formatMessage(messages.lastmodifiedby)}>
<EyeIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
@@ -120,39 +132,47 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="ml-2 flex flex-shrink-0 flex-wrap">
{request.status === MediaRequestStatus.PENDING && (
<>
<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>
<Tooltip content={intl.formatMessage(messages.approve)}>
<Button
buttonType="success"
className="mr-1"
onClick={() => updateRequest('approve')}
disabled={isUpdating}
>
<CheckIcon className="icon-sm" />
</Button>
</Tooltip>
<Tooltip content={intl.formatMessage(messages.decline)}>
<Button
buttonType="danger"
className="mr-1"
onClick={() => updateRequest('decline')}
disabled={isUpdating}
>
<XIcon />
</Button>
</Tooltip>
<Tooltip content={intl.formatMessage(messages.edit)}>
<Button
buttonType="warning"
onClick={() => setShowEditModal(true)}
disabled={isUpdating}
>
<PencilIcon className="icon-sm" />
</Button>
</Tooltip>
</>
)}
{request.status !== MediaRequestStatus.PENDING && (
<Button
buttonType="danger"
onClick={() => deleteRequest()}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
</Button>
<Tooltip content={intl.formatMessage(messages.delete)}>
<Button
buttonType="danger"
onClick={() => deleteRequest()}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
</Button>
</Tooltip>
)}
</div>
</div>
@@ -179,10 +199,17 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
{request.status === MediaRequestStatus.FAILED && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.failed)}
</Badge>
)}
</div>
</div>
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<Tooltip content={intl.formatMessage(messages.requestdate)}>
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
{intl.formatDate(request.createdAt, {
year: 'numeric',

View File

@@ -1,23 +1,20 @@
import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown';
import RequestModal from '@app/components/RequestModal';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { DownloadIcon } from '@heroicons/react/outline';
import {
CheckIcon,
InformationCircleIcon,
XIcon,
} from '@heroicons/react/solid';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import React, { useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
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',
@@ -54,14 +51,14 @@ interface RequestButtonProps {
is4kShowComplete?: boolean;
}
const RequestButton: React.FC<RequestButtonProps> = ({
const RequestButton = ({
tmdbId,
onUpdate,
media,
mediaType,
isShowComplete = false,
is4kShowComplete = false,
}) => {
}: RequestButtonProps) => {
const intl = useIntl();
const settings = useSettings();
const { user, hasPermission } = useUser();
@@ -77,13 +74,13 @@ const RequestButton: React.FC<RequestButtonProps> = ({
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
// Current user's pending request, or the first pending request
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(
@@ -121,6 +118,151 @@ const RequestButton: React.FC<RequestButtonProps> = ({
};
const buttons: ButtonOption[] = [];
// If there are pending requests, show request management options first
if (activeRequest || active4kRequest) {
if (
activeRequest &&
(activeRequest.requestedBy.id === user?.id ||
(activeRequests?.length === 1 &&
hasPermission(Permission.MANAGE_REQUESTS)))
) {
buttons.push({
id: 'active-request',
text: intl.formatMessage(messages.viewrequest),
action: () => {
setEditRequest(true);
setShowRequestModal(true);
},
svg: <InformationCircleIcon />,
});
}
if (
activeRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-request',
text: intl.formatMessage(messages.approverequest),
action: () => {
modifyRequest(activeRequest, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-request',
text: intl.formatMessage(messages.declinerequest),
action: () => {
modifyRequest(activeRequest, 'decline');
},
svg: <XIcon />,
}
);
} else if (
activeRequests &&
activeRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-request-batch',
text: intl.formatMessage(messages.approverequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-request-batch',
text: intl.formatMessage(messages.declinerequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'decline');
},
svg: <XIcon />,
}
);
}
if (
active4kRequest &&
(active4kRequest.requestedBy.id === user?.id ||
(active4kRequests?.length === 1 &&
hasPermission(Permission.MANAGE_REQUESTS)))
) {
buttons.push({
id: 'active-4k-request',
text: intl.formatMessage(messages.viewrequest4k),
action: () => {
setEditRequest(true);
setShowRequest4kModal(true);
},
svg: <InformationCircleIcon />,
});
}
if (
active4kRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-4k-request',
text: intl.formatMessage(messages.approverequest4k),
action: () => {
modifyRequest(active4kRequest, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-4k-request',
text: intl.formatMessage(messages.declinerequest4k),
action: () => {
modifyRequest(active4kRequest, 'decline');
},
svg: <XIcon />,
}
);
} else if (
active4kRequests &&
active4kRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-4k-request-batch',
text: intl.formatMessage(messages.approve4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-4k-request-batch',
text: intl.formatMessage(messages.decline4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'decline');
},
svg: <XIcon />,
}
);
}
}
// Standard request button
if (
(!media || media.status === MediaStatus.UNKNOWN) &&
hasPermission(
@@ -142,8 +284,28 @@ const RequestButton: React.FC<RequestButtonProps> = ({
},
svg: <DownloadIcon />,
});
} else if (
mediaType === 'tv' &&
(!activeRequest || activeRequest.requestedBy.id !== user?.id) &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) &&
media &&
media.status !== MediaStatus.AVAILABLE &&
!isShowComplete
) {
buttons.push({
id: 'request-more',
text: intl.formatMessage(messages.requestmore),
action: () => {
setEditRequest(false);
setShowRequestModal(true);
},
svg: <DownloadIcon />,
});
}
// 4K request button
if (
(!media || media.status4k === MediaStatus.UNKNOWN) &&
hasPermission(
@@ -167,175 +329,7 @@ const RequestButton: React.FC<RequestButtonProps> = ({
},
svg: <DownloadIcon />,
});
}
if (
activeRequest &&
(activeRequest.requestedBy.id === user?.id ||
(activeRequests?.length === 1 &&
hasPermission(Permission.MANAGE_REQUESTS)))
) {
buttons.push({
id: 'active-request',
text: intl.formatMessage(messages.viewrequest),
action: () => {
setEditRequest(true);
setShowRequestModal(true);
},
svg: <InformationCircleIcon />,
});
}
if (
active4kRequest &&
(active4kRequest.requestedBy.id === user?.id ||
(active4kRequests?.length === 1 &&
hasPermission(Permission.MANAGE_REQUESTS)))
) {
buttons.push({
id: 'active-4k-request',
text: intl.formatMessage(messages.viewrequest4k),
action: () => {
setEditRequest(true);
setShowRequest4kModal(true);
},
svg: <InformationCircleIcon />,
});
}
if (
activeRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-request',
text: intl.formatMessage(messages.approverequest),
action: () => {
modifyRequest(activeRequest, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-request',
text: intl.formatMessage(messages.declinerequest),
action: () => {
modifyRequest(activeRequest, 'decline');
},
svg: <XIcon />,
}
);
}
if (
activeRequests &&
activeRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-request-batch',
text: intl.formatMessage(messages.approverequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-request-batch',
text: intl.formatMessage(messages.declinerequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'decline');
},
svg: <XIcon />,
}
);
}
if (
active4kRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-4k-request',
text: intl.formatMessage(messages.approverequest4k),
action: () => {
modifyRequest(active4kRequest, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-4k-request',
text: intl.formatMessage(messages.declinerequest4k),
action: () => {
modifyRequest(active4kRequest, 'decline');
},
svg: <XIcon />,
}
);
}
if (
active4kRequests &&
active4kRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-4k-request-batch',
text: intl.formatMessage(messages.approve4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'approve');
},
svg: <CheckIcon />,
},
{
id: 'decline-4k-request-batch',
text: intl.formatMessage(messages.decline4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'decline');
},
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 (
} else if (
mediaType === 'tv' &&
(!active4kRequest || active4kRequest.requestedBy.id !== user?.id) &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
@@ -343,7 +337,6 @@ const RequestButton: React.FC<RequestButtonProps> = ({
}) &&
media &&
media.status4k !== MediaStatus.AVAILABLE &&
media.status4k !== MediaStatus.UNKNOWN &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {

View File

@@ -1,3 +1,12 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers';
import {
CheckIcon,
PencilIcon,
@@ -5,33 +14,28 @@ import {
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import { MediaRequestStatus } 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 axios from 'axios';
import Link from 'next/link';
import React, { useEffect, useState } from 'react';
import { 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: '{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.',
mediaerror: '{mediaType} Not Found',
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
approverequest: 'Approve Request',
declinerequest: 'Decline Request',
editrequest: 'Edit Request',
cancelrequest: 'Cancel Request',
deleterequest: 'Delete Request',
});
@@ -39,7 +43,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const RequestCardPlaceholder: React.FC = () => {
const RequestCardPlaceholder = () => {
return (
<div className="relative w-72 animate-pulse rounded-xl bg-gray-700 p-4 sm:w-96">
<div className="w-20 sm:w-28">
@@ -50,37 +54,133 @@ const RequestCardPlaceholder: React.FC = () => {
};
interface RequestCardErrorProps {
mediaId?: number;
requestData?: MediaRequest;
}
const RequestCardError: React.FC<RequestCardErrorProps> = ({ mediaId }) => {
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
const { hasPermission } = useUser();
const intl = useIntl();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
};
return (
<div className="relative w-72 rounded-xl bg-gray-800 p-4 ring-1 ring-red-500 sm:w-96">
<div
className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 p-4 text-gray-400 shadow ring-1 ring-red-500 sm:w-96"
data-testid="request-card"
>
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }}>
<div className="absolute inset-0 flex h-full w-full flex-col items-center justify-center px-10">
<div className="w-full whitespace-normal text-center text-xs text-gray-300 sm:text-sm">
{intl.formatMessage(messages.mediaerror)}
<div className="absolute inset-0 z-10 flex min-w-0 flex-1 flex-col p-4">
<div
className="whitespace-normal text-base font-bold text-white sm:text-lg"
data-testid="request-card-title"
>
{intl.formatMessage(messages.mediaerror, {
mediaType: intl.formatMessage(
requestData?.type
? requestData?.type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
: globalMessages.request
),
})}
</div>
{hasPermission(Permission.MANAGE_REQUESTS) && mediaId && (
<Button
buttonType="danger"
buttonSize="sm"
className="mt-4"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
{requestData && (
<>
{hasPermission(
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
{ type: 'or' }
) && (
<div className="card-field !hidden sm:!block">
<Link href={`/users/${requestData.requestedBy.id}`}>
<a className="group flex items-center">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm"
/>
<span className="truncate group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
</div>
)}
<div className="mt-2 flex items-center text-sm sm:mt-1">
<span className="mr-2 hidden font-bold sm:block">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ||
requestData.status === MediaRequestStatus.FAILED ? (
<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.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={
requestData.is4k
? requestData.media.serviceUrl4k
: requestData.media.serviceUrl
}
/>
)}
</div>
</>
)}
<div className="flex flex-1 items-end space-x-2">
{hasPermission(Permission.MANAGE_REQUESTS) &&
requestData?.media.id && (
<>
<Button
buttonType="danger"
buttonSize="sm"
className="mt-4 hidden sm:block"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(globalMessages.delete)}</span>
</Button>
<Tooltip
content={intl.formatMessage(messages.deleterequest)}
>
<Button
buttonType="danger"
buttonSize="sm"
className="mt-4 sm:hidden"
onClick={() => deleteRequest()}
>
<TrashIcon />
</Button>
</Tooltip>
</>
)}
</div>
</div>
</div>
</div>
@@ -93,7 +193,7 @@ interface RequestCardProps {
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
}
const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -168,7 +268,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
}
if (!title || !requestData) {
return <RequestCardError mediaId={requestData?.media.id} />;
return <RequestCardError requestData={requestData} />;
}
return (
@@ -185,7 +285,10 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
setShowEditModal(false);
}}
/>
<div className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96">
<div
className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96"
data-testid="request-card"
>
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
@@ -203,7 +306,10 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
/>
</div>
)}
<div className="relative z-10 flex min-w-0 flex-1 flex-col pr-4">
<div
className="relative z-10 flex min-w-0 flex-1 flex-col pr-4"
data-testid="request-card-title"
>
<div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
@@ -275,8 +381,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.declined)}
</Badge>
) : requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN ? (
) : requestData.status === MediaRequestStatus.FAILED ? (
<Badge
badgeType="danger"
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
@@ -299,17 +404,20 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
requestData.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={
requestData.is4k
? requestData.media.serviceUrl4k
: requestData.media.serviceUrl
}
/>
)}
</div>
<div className="flex flex-1 items-end space-x-2">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
{requestData.status === MediaRequestStatus.FAILED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="primary"
@@ -329,26 +437,52 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<Button
buttonType="success"
buttonSize="sm"
onClick={() => modifyRequest('approve')}
>
<CheckIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.approve)}
</span>
</Button>
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => modifyRequest('decline')}
>
<XIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.decline)}
</span>
</Button>
<div>
<Button
buttonType="success"
buttonSize="sm"
className="hidden sm:block"
onClick={() => modifyRequest('approve')}
>
<CheckIcon />
<span>{intl.formatMessage(globalMessages.approve)}</span>
</Button>
<Tooltip
content={intl.formatMessage(messages.approverequest)}
>
<Button
buttonType="success"
buttonSize="sm"
className="sm:hidden"
onClick={() => modifyRequest('approve')}
>
<CheckIcon />
</Button>
</Tooltip>
</div>
<div>
<Button
buttonType="danger"
buttonSize="sm"
className="hidden sm:block"
onClick={() => modifyRequest('decline')}
>
<XIcon />
<span>{intl.formatMessage(globalMessages.decline)}</span>
</Button>
<Tooltip
content={intl.formatMessage(messages.declinerequest)}
>
<Button
buttonType="danger"
buttonSize="sm"
className="sm:hidden"
onClick={() => modifyRequest('decline')}
>
<XIcon />
</Button>
</Tooltip>
</div>
</>
)}
{requestData.status === MediaRequestStatus.PENDING &&
@@ -356,33 +490,54 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
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="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.edit)}
</span>
</Button>
<div>
{!hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="primary"
buttonSize="sm"
className="hidden sm:block"
onClick={() => setShowEditModal(true)}
>
<PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</Button>
)}
<Tooltip content={intl.formatMessage(messages.editrequest)}>
<Button
buttonType="primary"
buttonSize="sm"
className="sm:hidden"
onClick={() => setShowEditModal(true)}
>
<PencilIcon />
</Button>
</Tooltip>
</div>
)}
{requestData.status === MediaRequestStatus.PENDING &&
!hasPermission(Permission.MANAGE_REQUESTS) &&
requestData.requestedBy.id === user?.id && (
<Button
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<XIcon style={{ marginRight: '0' }} />
<span className="ml-1.5 hidden sm:block">
{intl.formatMessage(globalMessages.cancel)}
</span>
</Button>
<div>
<Button
buttonType="danger"
buttonSize="sm"
className="hidden sm:block"
onClick={() => deleteRequest()}
>
<XIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
<Tooltip content={intl.formatMessage(messages.cancelrequest)}>
<Button
buttonType="danger"
buttonSize="sm"
className="sm:hidden"
onClick={() => deleteRequest()}
>
<XIcon />
</Button>
</Tooltip>
</div>
)}
</div>
</div>

View File

@@ -1,3 +1,11 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import {
CheckIcon,
PencilIcon,
@@ -5,28 +13,17 @@ import {
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
import { MediaRequestStatus } 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 axios from 'axios';
import Link from 'next/link';
import React, { useState } from 'react';
import { 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 {
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 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: '{seasonCount, plural, one {Season} other {Seasons}}',
@@ -35,50 +32,227 @@ const messages = defineMessages({
requesteddate: 'Requested',
modified: 'Modified',
modifieduserdate: '{date} by {user}',
mediaerror: 'The associated title for this request is no longer available.',
mediaerror: '{mediaType} Not Found',
editrequest: 'Edit Request',
deleterequest: 'Delete Request',
cancelRequest: 'Cancel Request',
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
interface RequestItemErroProps {
mediaId?: number;
interface RequestItemErrorProps {
requestData?: MediaRequest;
revalidateList: () => void;
}
const RequestItemError: React.FC<RequestItemErroProps> = ({
mediaId,
const RequestItemError = ({
requestData,
revalidateList,
}) => {
}: RequestItemErrorProps) => {
const intl = useIntl();
const { hasPermission } = useUser();
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${mediaId}`);
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
revalidateList();
};
return (
<div className="flex h-64 w-full flex-col items-center justify-center rounded-xl bg-gray-800 px-10 ring-1 ring-red-500 lg:flex-row xl:h-28">
<span className="text-center text-sm 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">
<div className="flex h-64 w-full flex-col justify-center rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-red-500 xl:h-28 xl:flex-row">
<div className="flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="flex w-full flex-col justify-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<div className="flex text-lg font-bold text-white xl:text-xl">
{intl.formatMessage(messages.mediaerror, {
mediaType: intl.formatMessage(
requestData?.type
? requestData?.type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
: globalMessages.request
),
})}
</div>
{requestData && hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.tmdbid)}
</span>
<span className="flex truncate text-sm text-gray-300">
{requestData.media.tmdbId}
</span>
</div>
{requestData.media.tvdbId && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.tvdbid)}
</span>
<span className="flex truncate text-sm text-gray-300">
{requestData?.media.tvdbId}
</span>
</div>
)}
</>
)}
</div>
<div className="mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
{requestData && (
<>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.status)}
</span>
{requestData.status === MediaRequestStatus.DECLINED ||
requestData.status === MediaRequestStatus.FAILED ? (
<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.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={
requestData.is4k
? requestData.media.serviceUrl4k
: requestData.media.serviceUrl
}
/>
)}
</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 truncate text-sm text-gray-300">
{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="group flex items-center truncate">
<img
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm group-hover:underline">
{requestData.requestedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</>
) : (
<>
<span className="card-field-name">
{intl.formatMessage(messages.requesteddate)}
</span>
<span className="flex truncate text-sm text-gray-300">
<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 truncate text-sm text-gray-300">
{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="group flex items-center truncate">
<img
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm ml-1.5"
/>
<span className="truncate text-sm group-hover:underline">
{requestData.modifiedBy.displayName}
</span>
</a>
</Link>
),
})}
</span>
</div>
)}
</>
)}
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
{hasPermission(Permission.MANAGE_REQUESTS) && requestData?.media.id && (
<Button
className="w-full"
buttonType="danger"
buttonSize="sm"
onClick={() => deleteRequest()}
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</Button>
</div>
)}
)}
</div>
</div>
);
};
@@ -88,10 +262,7 @@ interface RequestItemProps {
revalidateList: () => void;
}
const RequestItem: React.FC<RequestItemProps> = ({
request,
revalidateList,
}) => {
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -157,7 +328,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
if (!title || !requestData) {
return (
<RequestItemError
mediaId={requestData?.media.id}
requestData={requestData}
revalidateList={revalidateList}
/>
);
@@ -276,9 +447,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.declined)}
</Badge>
) : requestData.media[
requestData.is4k ? 'status4k' : 'status'
] === MediaStatus.UNKNOWN ? (
) : requestData.status === MediaRequestStatus.FAILED ? (
<Badge
badgeType="danger"
href={`/${requestData.type}/${requestData.media.tmdbId}?manage=1`}
@@ -301,9 +470,14 @@ const RequestItem: React.FC<RequestItemProps> = ({
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
requestData.is4k
? requestData.media.mediaUrl4k
: requestData.media.mediaUrl
}
serviceUrl={
requestData.is4k
? requestData.media.serviceUrl4k
: requestData.media.serviceUrl
}
/>
)}
@@ -405,9 +579,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
{requestData.media[requestData.is4k ? 'status4k' : 'status'] ===
MediaStatus.UNKNOWN &&
requestData.status !== MediaRequestStatus.DECLINED &&
{requestData.status === MediaRequestStatus.FAILED &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
className="w-full"

View File

@@ -1,23 +1,23 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import RequestItem from '@app/components/RequestList/RequestItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import { useUpdateQueryParams } from '../../hooks/useUpdateQueryParams';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Button from '../Common/Button';
import Header from '../Common/Header';
import LoadingSpinner from '../Common/LoadingSpinner';
import PageTitle from '../Common/PageTitle';
import RequestItem from './RequestItem';
const messages = defineMessages({
requests: 'Requests',
@@ -33,16 +33,18 @@ enum Filter {
PROCESSING = 'processing',
AVAILABLE = 'available',
UNAVAILABLE = 'unavailable',
FAILED = 'failed',
}
type Sort = 'added' | 'modified';
const RequestList: React.FC = () => {
const RequestList = () => {
const router = useRouter();
const intl = useIntl();
const { user } = useUser({
id: Number(router.query.userId),
});
const { user: currentUser } = useUser();
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
@@ -59,7 +61,11 @@ const RequestList: React.FC = () => {
`/api/v1/request?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&filter=${currentFilter}&sort=${currentSort}${
router.query.userId ? `&requestedBy=${router.query.userId}` : ''
router.pathname.startsWith('/profile')
? `&requestedBy=${currentUser?.id}`
: router.query.userId
? `&requestedBy=${router.query.userId}`
: ''
}`
);
@@ -115,7 +121,11 @@ const RequestList: React.FC = () => {
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header
subtext={
router.query.userId ? (
router.pathname.startsWith('/profile') ? (
<Link href={`/profile`}>
<a className="hover:underline">{currentUser?.displayName}</a>
</Link>
) : router.query.userId ? (
<Link href={`/users/${user?.id}`}>
<a className="hover:underline">{user?.displayName}</a>
</Link>
@@ -158,6 +168,9 @@ const RequestList: React.FC = () => {
<option value="processing">
{intl.formatMessage(globalMessages.processing)}
</option>
<option value="failed">
{intl.formatMessage(globalMessages.failed)}
</option>
<option value="available">
{intl.formatMessage(globalMessages.available)}
</option>
@@ -238,9 +251,9 @@ const RequestList: React.FC = () => {
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: function strong(msg) {
return <span className="font-medium">{msg}</span>;
},
strong: (msg: React.ReactNode) => (
<span className="font-medium">{msg}</span>
),
})}
</p>
</div>

View File

@@ -1,21 +1,22 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { formatBytes } from '@app/utils/numberHelpers';
import { Listbox, Transition } from '@headlessui/react';
import { AdjustmentsIcon } from '@heroicons/react/outline';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Select from 'react-select';
import useSWR from 'swr';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../../../../server/interfaces/api/serviceInterfaces';
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';
} from '@server/interfaces/api/serviceInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Select from 'react-select';
import useSWR from 'swr';
type OptionType = {
value: number;
@@ -55,16 +56,16 @@ interface AdvancedRequesterProps {
onChange: (overrides: RequestOverrides) => void;
}
const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
const AdvancedRequester = ({
type,
is4k = false,
isAnime = false,
defaultOverrides,
requestUser,
onChange,
}) => {
}: AdvancedRequesterProps) => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
{
@@ -113,16 +114,41 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
);
const { data: userData } = useSWR<UserResultsResponse>(
hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS])
currentHasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS])
? '/api/v1/user?take=1000&sort=displayname'
: null
);
const filteredUserData = useMemo(
() =>
userData?.results.filter((user) =>
hasPermission(
is4k
? [
Permission.REQUEST_4K,
type === 'movie'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
]
: [
Permission.REQUEST,
type === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
user.permissions,
{ type: 'or' }
)
),
[userData?.results]
);
useEffect(() => {
if (userData?.results && !requestUser) {
setSelectedUser(userData.results.find((u) => u.id === user?.id) ?? null);
if (filteredUserData && !requestUser) {
setSelectedUser(
filteredUserData.find((u) => u.id === currentUser?.id) ?? null
);
}
}, [userData?.results]);
}, [filteredUserData]);
useEffect(() => {
let defaultServer = data?.find(
@@ -273,18 +299,17 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
serverData.rootFolders.length < 2 &&
(serverData.languageProfiles ?? []).length < 2 &&
!serverData.tags?.length)))) &&
(!selectedUser || (userData?.results ?? []).length < 2)
(!selectedUser || (filteredUserData ?? []).length < 2)
) {
return null;
}
return (
<>
<div className="mt-4 mb-2 flex items-center font-bold tracking-wider">
<AdjustmentsIcon className="mr-1.5 h-5 w-5" />
<div className="mt-4 mb-2 flex items-center text-lg font-semibold">
{intl.formatMessage(messages.advancedoptions)}
</div>
<div className="rounded-md bg-gray-600 p-4 shadow">
<div className="rounded-md">
{!!data && selectedServer !== null && (
<div className="flex flex-col md:flex-row">
{data.filter((server) => server.is4k === is4k).length > 1 && (
@@ -513,9 +538,12 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
/>
</div>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
{currentHasPermission([
Permission.MANAGE_REQUESTS,
Permission.MANAGE_USERS,
]) &&
selectedUser &&
(userData?.results ?? []).length > 1 && (
(filteredUserData ?? []).length > 1 && (
<Listbox
as="div"
value={selectedUser}
@@ -560,13 +588,13 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="mt-1 w-full rounded-md bg-gray-800 shadow-lg"
className="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 shadow-lg"
>
<Listbox.Options
static
className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
{userData?.results.map((user) => (
{filteredUserData?.map((user) => (
<Listbox.Option key={user.id} value={user}>
{({ selected, active }) => (
<div

View File

@@ -1,31 +1,28 @@
import { DownloadIcon } from '@heroicons/react/outline';
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester';
import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester';
import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { Collection } from '@server/models/Collection';
import axios from 'axios';
import React, { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../server/constants/media';
import { MediaRequest } from '../../../server/entity/MediaRequest';
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
import { Permission } from '../../../server/lib/permissions';
import { Collection } from '../../../server/models/Collection';
import { useUser } from '../../hooks/useUser';
import globalMessages from '../../i18n/globalMessages';
import Alert from '../Common/Alert';
import Badge from '../Common/Badge';
import CachedImage from '../Common/CachedImage';
import Modal from '../Common/Modal';
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
import QuotaDisplay from './QuotaDisplay';
const messages = defineMessages({
requestadmin: 'This request will be approved automatically.',
requestSuccess: '<strong>{title}</strong> requested successfully!',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
requestcollectiontitle: 'Request Collection',
requestcollection4ktitle: 'Request Collection in 4K',
requesterror: 'Something went wrong while submitting the request.',
selectmovies: 'Select Movie(s)',
requestmovies: 'Request {count} {count, plural, one {Movie} other {Movies}}',
@@ -41,13 +38,13 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
onUpdating?: (isUpdating: boolean) => void;
}
const CollectionRequestModal: React.FC<RequestModalProps> = ({
const CollectionRequestModal = ({
onCancel,
onComplete,
tmdbId,
onUpdating,
is4k = false,
}) => {
}: RequestModalProps) => {
const [isUpdating, setIsUpdating] = useState(false);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
@@ -220,9 +217,7 @@ const CollectionRequestModal: React.FC<RequestModalProps> = ({
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
@@ -253,9 +248,11 @@ const CollectionRequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
onOk={sendRequest}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.name }
is4k
? messages.requestcollection4ktitle
: messages.requestcollectiontitle
)}
subTitle={data?.name}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
@@ -270,7 +267,6 @@ const CollectionRequestModal: React.FC<RequestModalProps> = ({
}
okDisabled={selectedParts.length === 0}
okButtonType={'primary'}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{hasAutoApprove && !quota?.movie.restricted && (
@@ -296,11 +292,11 @@ const CollectionRequestModal: React.FC<RequestModalProps> = ({
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0">
<div className="inline-block min-w-full py-2 align-middle">
<div className="overflow-hidden shadow sm:rounded-lg">
<div className="overflow-hidden border border-gray-700 backdrop-blur sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="w-16 bg-gray-500 px-4 py-3">
<th className="w-16 bg-gray-700 bg-opacity-80 px-4 py-3">
<span
role="checkbox"
tabIndex={0}
@@ -332,15 +328,15 @@ const CollectionRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</th>
<th className="bg-gray-500 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700 bg-opacity-80 px-1 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.movie)}
</th>
<th className="bg-gray-500 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
<th className="bg-gray-700 bg-opacity-80 px-2 py-3 text-left text-xs font-medium uppercase leading-4 tracking-wider text-gray-200 md:px-6">
{intl.formatMessage(globalMessages.status)}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
<tbody className="divide-y divide-gray-700">
{data?.parts.map((part) => {
const partRequest = getPartRequest(part.id);
const partMedia =
@@ -382,7 +378,7 @@ const CollectionRequestModal: React.FC<RequestModalProps> = ({
partRequest ||
isSelectedPart(part.id)
? 'bg-indigo-500'
: 'bg-gray-800'
: 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span

View File

@@ -1,32 +1,32 @@
import { DownloadIcon } from '@heroicons/react/outline';
import Alert from '@app/components/Common/Alert';
import Modal from '@app/components/Common/Modal';
import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester';
import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester';
import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { MovieDetails } from '@server/models/Movie';
import axios from 'axios';
import React, { useCallback, useEffect, useState } from 'react';
import { 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';
import useSWR, { mutate } from 'swr';
const messages = defineMessages({
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',
requestmovietitle: 'Request Movie',
requestmovie4ktitle: 'Request Movie in 4K',
edit: 'Edit Request',
approve: 'Approve Request',
cancel: 'Cancel Request',
pendingrequest: 'Pending Request for {title}',
pending4krequest: 'Pending 4K Request for {title}',
pendingrequest: 'Pending Movie Request',
pending4krequest: 'Pending 4K Movie Request',
requestfrom: "{username}'s request is pending approval.",
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
@@ -44,14 +44,14 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
onUpdating?: (isUpdating: boolean) => void;
}
const MovieRequestModal: React.FC<RequestModalProps> = ({
const MovieRequestModal = ({
onCancel,
onComplete,
tmdbId,
onUpdating,
editRequest,
is4k = false,
}) => {
}: RequestModalProps) => {
const [isUpdating, setIsUpdating] = useState(false);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
@@ -94,6 +94,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
is4k,
...overrideParams,
});
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.data) {
if (onComplete) {
@@ -114,9 +115,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.title,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
@@ -139,6 +138,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
const response = await axios.delete<MediaRequest>(
`/api/v1/request/${editRequest?.id}`
);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.status === 204) {
if (onComplete) {
@@ -148,9 +148,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
<span>
{intl.formatMessage(messages.requestCancel, {
title: data?.title,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
@@ -177,6 +175,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
addToast(
<span>
@@ -186,9 +185,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
: messages.requestedited,
{
title: data?.title,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}
)}
</span>,
@@ -220,9 +217,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
backgroundClickable
onCancel={onCancel}
title={intl.formatMessage(
is4k ? messages.pending4krequest : messages.pendingrequest,
{ title: data?.title }
is4k ? messages.pending4krequest : messages.pendingrequest
)}
subTitle={data?.title}
onOk={() =>
hasPermission(Permission.MANAGE_REQUESTS)
? updateRequest(true)
@@ -266,7 +263,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
}
secondaryButtonType="danger"
cancelText={intl.formatMessage(globalMessages.close)}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{isOwner
@@ -312,9 +308,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onOk={sendRequest}
okDisabled={isUpdating || quota?.movie.restricted}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.title }
is4k ? messages.requestmovie4ktitle : messages.requestmovietitle
)}
subTitle={data?.title}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
@@ -323,7 +319,6 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
)
}
okButtonType={'primary'}
iconSvg={<DownloadIcon />}
backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`}
>
{hasAutoApprove && !quota?.movie.restricted && (

View File

@@ -1,9 +1,9 @@
import ProgressCircle from '@app/components/Common/ProgressCircle';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid';
import type { QuotaStatus } from '@server/interfaces/api/userInterfaces';
import Link from 'next/link';
import React, { useState } from 'react';
import { 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:
@@ -35,18 +35,18 @@ interface QuotaDisplayProps {
overLimit?: number;
}
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
const QuotaDisplay = ({
quota,
mediaType,
userOverride,
remaining,
overLimit,
}) => {
}: QuotaDisplayProps) => {
const intl = useIntl();
const [showDetails, setShowDetails] = useState(false);
return (
<div
className="my-4 flex flex-col rounded-md bg-gray-800 p-4"
className="my-4 flex flex-col rounded-md border border-gray-700 p-4 backdrop-blur"
onClick={() => setShowDetails((s) => !s)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
@@ -79,9 +79,7 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
type: intl.formatMessage(
mediaType === 'movie' ? messages.movie : messages.season
),
strong: function strong(msg) {
return <span className="font-bold">{msg}</span>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</div>
</div>
@@ -103,9 +101,7 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
: messages.requiredquota,
{
seasons: overLimit,
strong: function strong(msg) {
return <span className="font-bold">{msg}</span>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}
)}
</div>
@@ -124,9 +120,7 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
: messages.seasonlimit,
{ limit: quota?.limit }
),
strong: function strong(msg) {
return <span className="font-bold">{msg}</span>;
},
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}
)}
</div>
@@ -134,19 +128,15 @@ const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
{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>
);
},
ProfileLink: (msg: React.ReactNode) => (
<Link
href={userOverride ? `/users/${userOverride}` : '/profile'}
>
<a className="text-white transition duration-300 hover:underline">
{msg}
</a>
</Link>
),
}
)}
</div>

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