mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-31 19:59:31 -05:00
Merge remote-tracking branch 'overseerr/develop' into develop
This commit is contained in:
1
src/assets/infinity.svg
Normal file
1
src/assets/infinity.svg
Normal 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 |
62
src/components/AirDateBadge/index.tsx
Normal file
62
src/components/AirDateBadge/index.tsx
Normal 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;
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
38
src/components/Common/Tooltip/index.tsx
Normal file
38
src/components/Common/Tooltip/index.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
84
src/components/Discover/DiscoverWatchlist/index.tsx
Normal file
84
src/components/Discover/DiscoverWatchlist/index.tsx
Normal 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;
|
||||
@@ -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`
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user