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

This commit is contained in:
notfakie
2023-01-27 17:55:55 +13:00
158 changed files with 10835 additions and 4524 deletions

View File

@@ -10,7 +10,7 @@ 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 { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { Collection } from '@server/models/Collection';
import { uniq } from 'lodash';
@@ -276,7 +276,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
}}
text={
<>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>
{intl.formatMessage(
hasRequestable
@@ -295,7 +295,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
setIs4k(true);
}}
>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>

View File

@@ -1,8 +1,8 @@
import {
ExclamationIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
XCircleIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
interface AlertProps {
title?: React.ReactNode;
@@ -16,7 +16,7 @@ const Alert = ({ title, children, type }: AlertProps) => {
'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" />,
svg: <ExclamationTriangleIcon className="h-5 w-5" />,
};
switch (type) {

View File

@@ -46,7 +46,7 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
'inline-flex items-center justify-center border leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50 whitespace-nowrap',
];
switch (buttonType) {
case 'primary':
@@ -71,7 +71,7 @@ function Button<P extends ElementTypes = 'button'>(
break;
case 'ghost':
buttonStyle.push(
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:

View File

@@ -1,7 +1,7 @@
import useClickOutside from '@app/hooks/useClickOutside';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import { Fragment, useRef, useState } from 'react';

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import useClickOutside from '@app/hooks/useClickOutside';
import { useRef, useState } from 'react';
import { forwardRef, useRef, useState } from 'react';
interface ConfirmButtonProps {
onClick: () => void;
@@ -9,50 +9,51 @@ interface ConfirmButtonProps {
children: React.ReactNode;
}
const ConfirmButton = ({
onClick,
children,
confirmText,
className,
}: ConfirmButtonProps) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
const ConfirmButton = forwardRef<HTMLButtonElement, ConfirmButtonProps>(
({ onClick, children, confirmText, className }, parentRef) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
ref={parentRef}
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
<div
ref={ref}
className={`relative inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute inset-0 flex h-full w-full transform-gpu items-center justify-center transition duration-300 ${
isClicked
? 'translate-y-0 opacity-100'
: 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
}
);
ConfirmButton.displayName = 'ConfirmButton';
export default ConfirmButton;

View File

@@ -0,0 +1,113 @@
import Tooltip from '@app/components/Common/Tooltip';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useEffect, useRef } from 'react';
type MultiRangeSliderProps = {
min: number;
max: number;
defaultMinValue?: number;
defaultMaxValue?: number;
subText?: string;
onUpdateMin: (min: number) => void;
onUpdateMax: (max: number) => void;
};
const MultiRangeSlider = ({
min,
max,
defaultMinValue,
defaultMaxValue,
subText,
onUpdateMin,
onUpdateMax,
}: MultiRangeSliderProps) => {
const touched = useRef(false);
const [valueMin, finalValueMin, setValueMin] = useDebouncedState(
defaultMinValue ?? min
);
const [valueMax, finalValueMax, setValueMax] = useDebouncedState(
defaultMaxValue ?? max
);
const minThumb = ((valueMin - min) / (max - min)) * 100;
const maxThumb = ((valueMax - min) / (max - min)) * 100;
useEffect(() => {
if (touched.current) {
onUpdateMin(finalValueMin);
}
}, [finalValueMin, onUpdateMin]);
useEffect(() => {
if (touched.current) {
onUpdateMax(finalValueMax);
}
}, [finalValueMax, onUpdateMax]);
useEffect(() => {
touched.current = false;
setValueMax(defaultMaxValue ?? max);
setValueMin(defaultMinValue ?? min);
}, [defaultMinValue, defaultMaxValue, setValueMax, setValueMin, min, max]);
return (
<div className={`relative ${subText ? 'h-8' : 'h-4'} w-full`}>
<Tooltip
content={valueMin.toString()}
tooltipConfig={{
placement: 'top',
}}
>
<input
type="range"
min={min}
max={max}
value={valueMin}
className={`pointer-events-none absolute h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-700 ${
valueMin >= valueMax && valueMin !== min ? 'z-30' : 'z-10'
}`}
onChange={(e) => {
const value = Number(e.target.value);
if (value <= valueMax) {
touched.current = true;
setValueMin(value);
}
}}
/>
</Tooltip>
<Tooltip content={valueMax}>
<input
type="range"
min={min}
max={max}
value={valueMax}
step="1"
className={`pointer-events-none absolute top-0 left-0 right-0 z-20 h-2 w-full cursor-pointer appearance-none rounded-lg bg-transparent`}
onChange={(e) => {
const value = Number(e.target.value);
if (value >= valueMin) {
touched.current = true;
setValueMax(value);
}
}}
/>
</Tooltip>
<div
className="pointer-events-none absolute top-0 z-30 ml-1 mr-1 h-2 bg-indigo-500"
style={{
left: `${minThumb}%`,
right: `${100 - maxThumb}%`,
}}
/>
{subText && (
<div className="relative top-4 z-30 flex w-full justify-center text-sm text-gray-400">
<span>{subText}</span>
</div>
)}
</div>
);
};
export default MultiRangeSlider;

View File

@@ -1,4 +1,4 @@
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid';
import { Field } from 'formik';
import { useState } from 'react';
@@ -43,7 +43,7 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
type="button"
className="input-action"
>
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
{isHidden ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</>
);

View File

@@ -0,0 +1,38 @@
type SlideCheckboxProps = {
onClick: () => void;
checked?: boolean;
};
const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => {
return (
<span
role="checkbox"
tabIndex={0}
aria-checked={false}
onClick={() => {
onClick();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Space') {
onClick();
}
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`}
>
<span
aria-hidden="true"
className={`${
checked ? 'bg-indigo-500' : 'bg-gray-700'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
checked ? 'translate-x-5' : 'translate-x-0'
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
></span>
</span>
);
};
export default SlideCheckbox;

View File

@@ -1,7 +1,7 @@
/* 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 { XMarkIcon } from '@heroicons/react/24/outline';
import { Fragment, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
@@ -67,11 +67,11 @@ const SlideOver = ({
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="slideover h-full w-screen max-w-md p-2 sm:p-4"
className="slideover relative h-full w-screen max-w-md p-2 sm:p-4"
ref={slideoverRef}
onClick={(e) => e.stopPropagation()}
>
<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">
<div className="flex h-full flex-col 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-overseerr text-2xl font-bold leading-7">
@@ -83,7 +83,7 @@ const SlideOver = ({
className="text-gray-200 transition duration-150 ease-in-out hover:text-white"
onClick={() => onClose()}
>
<XIcon className="h-6 w-6" />
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
@@ -95,8 +95,10 @@ const SlideOver = ({
</div>
)}
</header>
<div className="relative flex-1 px-4 py-6 text-white">
{children}
<div className="hide-scrollbar flex flex-1 flex-col overflow-y-auto">
<div className="flex-1 px-4 py-6 text-white">
{children}
</div>
</div>
</div>
</div>

View File

@@ -1,41 +1,67 @@
import {
BellIcon,
CheckIcon,
ClockIcon,
MinusSmIcon,
} from '@heroicons/react/solid';
import Spinner from '@app/assets/spinner.svg';
import { CheckCircleIcon } from '@heroicons/react/20/solid';
import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
interface StatusBadgeMiniProps {
status: MediaStatus;
is4k?: boolean;
inProgress?: boolean;
// Should the badge shrink on mobile to a smaller size? (TitleCard)
shrink?: boolean;
}
const StatusBadgeMini = ({ status, is4k = false }: StatusBadgeMiniProps) => {
const badgeStyle = ['w-5 rounded-full p-0.5 text-white ring-1'];
const StatusBadgeMini = ({
status,
is4k = false,
inProgress = false,
shrink = false,
}: StatusBadgeMiniProps) => {
const badgeStyle = [
`rounded-full bg-opacity-80 shadow-md ${
shrink ? 'w-4 sm:w-5 border p-0' : 'w-5 ring-1 p-0.5'
}`,
];
let indicatorIcon: React.ReactNode;
switch (status) {
case MediaStatus.PROCESSING:
badgeStyle.push('bg-indigo-500 ring-indigo-400');
badgeStyle.push(
'bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100'
);
indicatorIcon = <ClockIcon />;
break;
case MediaStatus.AVAILABLE:
badgeStyle.push('bg-green-500 ring-green-400');
indicatorIcon = <CheckIcon />;
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <CheckCircleIcon />;
break;
case MediaStatus.PENDING:
badgeStyle.push('bg-yellow-500 ring-yellow-400');
badgeStyle.push(
'bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100'
);
indicatorIcon = <BellIcon />;
break;
case MediaStatus.PARTIALLY_AVAILABLE:
badgeStyle.push('bg-green-500 ring-green-400');
indicatorIcon = <MinusSmIcon />;
badgeStyle.push(
'bg-green-500 border-green-400 ring-green-400 text-green-100'
);
indicatorIcon = <MinusSmallIcon />;
break;
}
if (inProgress) {
indicatorIcon = <Spinner />;
}
return (
<div className="inline-flex whitespace-nowrap rounded-full text-xs font-semibold leading-5 ring-1 ring-gray-700">
<div
className={`relative inline-flex whitespace-nowrap rounded-full border-gray-700 text-xs font-semibold leading-5 ring-gray-700 ${
shrink ? '' : 'ring-1'
}`}
>
<div className={badgeStyle.join(' ')}>{indicatorIcon}</div>
{is4k && <span className="pl-1 pr-2 text-gray-200">4K</span>}
</div>

View File

@@ -0,0 +1,24 @@
import { TagIcon } from '@heroicons/react/24/outline';
import React from 'react';
type TagProps = {
children: React.ReactNode;
iconSvg?: JSX.Element;
};
const Tag = ({ children, iconSvg }: TagProps) => {
return (
<div className="inline-flex cursor-pointer items-center rounded-full bg-gray-800 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-600 transition hover:bg-gray-700">
{iconSvg ? (
React.cloneElement(iconSvg, {
className: 'mr-1 h-4 w-4',
})
) : (
<TagIcon className="mr-1 h-4 w-4" />
)}
<span>{children}</span>
</div>
);
};
export default Tag;

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { BuildingOffice2Icon } from '@heroicons/react/24/outline';
import type { ProductionCompany, TvNetwork } from '@server/models/common';
import useSWR from 'swr';
type CompanyTagProps = {
type: 'studio' | 'network';
companyId: number;
};
const CompanyTag = ({ companyId, type }: CompanyTagProps) => {
const { data, error } = useSWR<TvNetwork | ProductionCompany>(
`/api/v1/${type}/${companyId}`
);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag iconSvg={<BuildingOffice2Icon />}>{data?.name}</Tag>;
};
export default CompanyTag;

View File

@@ -0,0 +1,506 @@
import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import MediaSlider from '@app/components/MediaSlider';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { Keyword, ProductionCompany } from '@server/models/common';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import AsyncSelect from 'react-select/async';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages({
addSlider: 'Add Slider',
editSlider: 'Edit Slider',
slidernameplaceholder: 'Slider Name',
providetmdbkeywordid: 'Provide a TMDB Keyword ID',
providetmdbgenreid: 'Provide a TMDB Genre ID',
providetmdbsearch: 'Provide a search query',
providetmdbstudio: 'Provide TMDB Studio ID',
providetmdbnetwork: 'Provide TMDB Network ID',
addsuccess: 'Created new slider and saved discover customization settings.',
addfail: 'Failed to create new slider.',
editsuccess: 'Edited slider and saved discover customization settings.',
editfail: 'Failed to edit slider.',
needresults: 'You need to have at least 1 result.',
validationDatarequired: 'You must provide a data value.',
validationTitlerequired: 'You must provide a title.',
addcustomslider: 'Create Custom Slider',
searchKeywords: 'Search keywords…',
searchGenres: 'Search genres…',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
});
type CreateSliderProps = {
onCreate: () => void;
slider?: Partial<DiscoverSlider>;
};
type CreateOption = {
type: DiscoverSliderType;
title: string;
dataUrl: string;
params?: string;
titlePlaceholderText: string;
dataPlaceholderText: string;
};
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [resultCount, setResultCount] = useState(0);
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
if (slider) {
const loadDefaultKeywords = async (): Promise<void> => {
if (!slider.data) {
return;
}
const keywords = await Promise.all(
slider.data.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
const loadDefaultGenre = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<TmdbGenre[]>(
`/api/v1/genres/${
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
}`
);
const genre = response.data.find(
(genre) => genre.id === Number(slider.data)
);
setDefaultDataValue([
{
label: genre?.name ?? '',
value: genre?.id ?? 0,
},
]);
};
const loadDefaultCompany = async (): Promise<void> => {
if (!slider.data) {
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${slider.data}`
);
const studio = response.data;
setDefaultDataValue([
{
label: studio.name ?? '',
value: studio.id ?? 0,
},
]);
};
switch (slider.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
loadDefaultKeywords();
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
case DiscoverSliderType.TMDB_TV_GENRE:
loadDefaultGenre();
break;
case DiscoverSliderType.TMDB_STUDIO:
loadDefaultCompany();
break;
}
}
}, [slider]);
const CreateSliderSchema = Yup.object().shape({
title: Yup.string().required(
intl.formatMessage(messages.validationTitlerequired)
),
data: Yup.string().required(
intl.formatMessage(messages.validationDatarequired)
),
});
const updateResultCount = useCallback(
(count: number) => {
setResultCount(count);
},
[setResultCount]
);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadCompanyOptions = async (inputValue: string) => {
if (inputValue === '') {
return [];
}
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadMovieGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/movie'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const loadTvGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
'/api/v1/discover/genreslider/tv'
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
const options: CreateOption[] = [
{
type: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbmoviekeyword),
dataUrl: '/api/v1/discover/movies',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_TV_KEYWORD,
title: intl.formatMessage(sliderTitles.tmdbtvkeyword),
dataUrl: '/api/v1/discover/tv',
params: 'keywords=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbkeywordid),
},
{
type: DiscoverSliderType.TMDB_MOVIE_GENRE,
title: intl.formatMessage(sliderTitles.tmdbmoviegenre),
dataUrl: '/api/v1/discover/movies/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_TV_GENRE,
title: intl.formatMessage(sliderTitles.tmdbtvgenre),
dataUrl: '/api/v1/discover/tv/genre/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbgenreid),
},
{
type: DiscoverSliderType.TMDB_STUDIO,
title: intl.formatMessage(sliderTitles.tmdbstudio),
dataUrl: '/api/v1/discover/movies/studio/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbstudio),
},
{
type: DiscoverSliderType.TMDB_NETWORK,
title: intl.formatMessage(sliderTitles.tmdbnetwork),
dataUrl: '/api/v1/discover/tv/network/$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbnetwork),
},
{
type: DiscoverSliderType.TMDB_SEARCH,
title: intl.formatMessage(sliderTitles.tmdbsearch),
dataUrl: '/api/v1/search',
params: 'query=$value',
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
},
];
return (
<Formik
initialValues={
slider
? {
sliderType: slider.type,
title: slider.title,
data: slider.data,
}
: {
sliderType: DiscoverSliderType.TMDB_MOVIE_KEYWORD,
title: '',
data: '',
}
}
validationSchema={CreateSliderSchema}
enableReinitialize
onSubmit={async (values, { resetForm }) => {
try {
if (slider) {
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
} else {
await axios.post('/api/v1/settings/discover/add', {
type: Number(values.sliderType),
title: values.title,
data: values.data,
});
}
addToast(
intl.formatMessage(
slider ? messages.editsuccess : messages.addsuccess
),
{
appearance: 'success',
autoDismiss: true,
}
);
onCreate();
resetForm();
} catch (e) {
addToast(
intl.formatMessage(slider ? messages.editfail : messages.addfail),
{
appearance: 'error',
autoDismiss: true,
}
);
}
}}
>
{({ values, isValid, isSubmitting, errors, touched, setFieldValue }) => {
const activeOption = options.find(
(option) => option.type === Number(values.sliderType)
);
let dataInput: React.ReactNode;
switch (activeOption?.type) {
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
case DiscoverSliderType.TMDB_TV_KEYWORD:
dataInput = (
<AsyncSelect
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
const keywords = value.map((item) => item.value).join(',');
setFieldValue('data', keywords);
}}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
dataInput = (
<AsyncSelect
key={`movie-genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadMovieGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
dataInput = (
<AsyncSelect
key={`tv-genre-select-${defaultDataValue}}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadTvGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
dataInput = (
<AsyncSelect
key={`studio-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={defaultDataValue?.[0]}
defaultOptions
cacheOptions
loadOptions={loadCompanyOptions}
placeholder={intl.formatMessage(messages.searchStudios)}
onChange={(value) => {
setFieldValue('data', value?.value.toString());
}}
/>
);
break;
default:
dataInput = (
<Field
type="text"
name="data"
id="data"
placeholder={activeOption?.dataPlaceholderText}
/>
);
}
return (
<Form data-testid="create-discover-option-form">
<div className="flex flex-col space-y-2 text-gray-100">
<Field as="select" id="sliderType" name="sliderType">
{options.map((option) => (
<option value={option.type} key={`type-${option.type}`}>
{option.title}
</option>
))}
</Field>
<Field
type="text"
name="title"
id="title"
placeholder={activeOption?.titlePlaceholderText}
/>
{errors.title &&
touched.title &&
typeof errors.title === 'string' && (
<div className="error">{errors.title}</div>
)}
{dataInput}
{errors.data &&
touched.data &&
typeof errors.data === 'string' && (
<div className="error">{errors.data}</div>
)}
<div className="flex-1"></div>
{resultCount === 0 ? (
<Tooltip content={intl.formatMessage(messages.needresults)}>
<div>
<Button buttonType="primary" buttonSize="sm" disabled>
{intl.formatMessage(messages.addSlider)}
</Button>
</div>
</Tooltip>
) : (
<div>
<Button
buttonType="primary"
buttonSize="sm"
disabled={isSubmitting || !isValid}
>
{intl.formatMessage(
slider ? messages.editSlider : messages.addSlider
)}
</Button>
</div>
)}
</div>
{activeOption && values.title && values.data && (
<div className="relative py-4">
<MediaSlider
sliderKey={`preview-${values.title}`}
title={values.title}
url={activeOption?.dataUrl.replace(
'$value',
encodeURIExtraParams(values.data)
)}
extraParams={activeOption.params?.replace(
'$value',
encodeURIExtraParams(values.data)
)}
onNewTitles={updateResultCount}
/>
</div>
)}
</Form>
);
}}
</Formik>
);
};
export default CreateSlider;

View File

@@ -1,16 +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 useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Popular Movies',
keywordMovies: '{keywordTitle} Movies',
});
const DiscoverMovies = () => {
const DiscoverMovieKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
@@ -21,13 +25,25 @@ const DiscoverMovies = () => {
titles,
fetchMore,
error,
} = useDiscover<MovieResult>('/api/v1/discover/movies');
firstResultData,
} = useDiscover<MovieResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/movies`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordMovies, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
@@ -48,4 +64,4 @@ const DiscoverMovies = () => {
);
};
export default DiscoverMovies;
export default DiscoverMovieKeyword;

View File

@@ -0,0 +1,147 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermovies: 'Movies',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortReleaseDateAsc: 'Release Date Ascending',
sortReleaseDateDesc: 'Release Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
ReleaseDateAsc: 'release_date.asc',
ReleaseDateDesc: 'release_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverMovies = () => {
const intl = useIntl();
const router = useRouter();
const updateQueryParams = useUpdateQueryParams({});
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<MovieResult, unknown, FilterOptions>(
'/api/v1/discover/movies',
preparedFilters
);
const [showFilters, setShowFilters] = useState(false);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermovies);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.ReleaseDateDesc}>
{intl.formatMessage(messages.sortReleaseDateDesc)}
</option>
<option value={SortOptions.ReleaseDateAsc}>
{intl.formatMessage(messages.sortReleaseDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="movie"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMovies;

View File

@@ -0,0 +1,334 @@
import Button from '@app/components/Common/Button';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import CompanyTag from '@app/components/CompanyTag';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import GenreTag from '@app/components/GenreTag';
import KeywordTag from '@app/components/KeywordTag';
import globalMessages from '@app/i18n/globalMessages';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import {
ArrowUturnLeftIcon,
Bars3Icon,
ChevronDownIcon,
ChevronUpIcon,
PencilIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-aria';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
deletesuccess: 'Sucessfully deleted slider.',
deletefail: 'Failed to delete slider.',
remove: 'Remove',
enable: 'Toggle Visibility',
});
const Position = {
None: 'None',
Above: 'Above',
Below: 'Below',
} as const;
type DiscoverSliderEditProps = {
slider: Partial<DiscoverSlider>;
onEnable: () => void;
onDelete: () => void;
onPositionUpdate: (
updatedItemId: number,
position: keyof typeof Position,
isClickable: boolean
) => void;
children: React.ReactNode;
disableUpButton: boolean;
disableDownButton: boolean;
};
const DiscoverSliderEdit = ({
slider,
children,
onEnable,
onDelete,
onPositionUpdate,
disableUpButton,
disableDownButton,
}: DiscoverSliderEditProps) => {
const intl = useIntl();
const { addToast } = useToasts();
const [isEditing, setIsEditing] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<keyof typeof Position>(
Position.None
);
const { dragProps, isDragging } = useDrag({
getItems() {
return [{ id: (slider.id ?? -1).toString(), title: slider.title ?? '' }];
},
});
const deleteSlider = async () => {
try {
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
addToast(intl.formatMessage(messages.deletesuccess), {
appearance: 'success',
autoDismiss: true,
});
onDelete();
} catch (e) {
addToast(intl.formatMessage(messages.deletefail), {
appearance: 'error',
autoDismiss: true,
});
}
};
const { dropProps } = useDrop({
ref,
onDropMove: (e) => {
if (ref.current) {
const middlePoint = ref.current.offsetHeight / 2;
if (e.y < middlePoint) {
setHoverPosition(Position.Above);
} else {
setHoverPosition(Position.Below);
}
}
},
onDropExit: () => {
setHoverPosition(Position.None);
},
onDrop: async (e) => {
const items = await Promise.all(
e.items
.filter((item) => item.kind === 'text' && item.types.has('id'))
.map(async (item) => {
if (item.kind === 'text') {
return item.getText('id');
}
})
);
if (items?.[0]) {
const dropped = Number(items[0]);
onPositionUpdate(dropped, hoverPosition, false);
}
},
});
const getSliderTitle = (slider: Partial<DiscoverSlider>): string => {
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
return intl.formatMessage(sliderTitles.recentlyAdded);
case DiscoverSliderType.RECENT_REQUESTS:
return intl.formatMessage(sliderTitles.recentrequests);
case DiscoverSliderType.PLEX_WATCHLIST:
return intl.formatMessage(sliderTitles.plexwatchlist);
case DiscoverSliderType.TRENDING:
return intl.formatMessage(sliderTitles.trending);
case DiscoverSliderType.POPULAR_MOVIES:
return intl.formatMessage(sliderTitles.popularmovies);
case DiscoverSliderType.MOVIE_GENRES:
return intl.formatMessage(sliderTitles.moviegenres);
case DiscoverSliderType.UPCOMING_MOVIES:
return intl.formatMessage(sliderTitles.upcoming);
case DiscoverSliderType.STUDIOS:
return intl.formatMessage(sliderTitles.studios);
case DiscoverSliderType.POPULAR_TV:
return intl.formatMessage(sliderTitles.populartv);
case DiscoverSliderType.TV_GENRES:
return intl.formatMessage(sliderTitles.tvgenres);
case DiscoverSliderType.UPCOMING_TV:
return intl.formatMessage(sliderTitles.upcomingtv);
case DiscoverSliderType.NETWORKS:
return intl.formatMessage(sliderTitles.networks);
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbmoviekeyword);
case DiscoverSliderType.TMDB_TV_KEYWORD:
return intl.formatMessage(sliderTitles.tmdbtvkeyword);
case DiscoverSliderType.TMDB_MOVIE_GENRE:
return intl.formatMessage(sliderTitles.tmdbmoviegenre);
case DiscoverSliderType.TMDB_TV_GENRE:
return intl.formatMessage(sliderTitles.tmdbtvgenre);
case DiscoverSliderType.TMDB_STUDIO:
return intl.formatMessage(sliderTitles.tmdbstudio);
case DiscoverSliderType.TMDB_NETWORK:
return intl.formatMessage(sliderTitles.tmdbnetwork);
case DiscoverSliderType.TMDB_SEARCH:
return intl.formatMessage(sliderTitles.tmdbsearch);
default:
return 'Unknown Slider';
}
};
return (
<div
key={`discover-slider-${slider.id}-editing`}
data-testid="discover-slider-edit-mode"
className={`relative mb-4 rounded-lg bg-gray-800 shadow-md ${
isDragging ? 'opacity-0' : 'opacity-100'
}`}
{...dragProps}
{...dropProps}
ref={ref}
>
{hoverPosition === Position.Above && (
<div
className={`absolute -top-3 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
{hoverPosition === Position.Below && (
<div
className={`absolute -bottom-2 left-0 w-full border-t-4 border-indigo-500`}
/>
)}
<div className="flex w-full flex-col rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-gray-400 md:flex-row md:items-center md:space-x-2">
<div
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
>
<Bars3Icon className="h-6 w-6" />
<div>{getSliderTitle(slider)}</div>
</div>
<div
className={`pointer-events-none ${
slider.data ? 'mb-4' : ''
} flex-1 md:mb-0`}
>
{(slider.type === DiscoverSliderType.TMDB_MOVIE_KEYWORD ||
slider.type === DiscoverSliderType.TMDB_TV_KEYWORD) && (
<div className="flex space-x-2">
{slider.data?.split(',').map((keywordId) => (
<KeywordTag
key={`slider-keywords-${slider.id}-${keywordId}`}
keywordId={Number(keywordId)}
/>
))}
</div>
)}
{(slider.type === DiscoverSliderType.TMDB_NETWORK ||
slider.type === DiscoverSliderType.TMDB_STUDIO) && (
<CompanyTag
type={
slider.type === DiscoverSliderType.TMDB_STUDIO
? 'studio'
: 'network'
}
companyId={Number(slider.data)}
/>
)}
{(slider.type === DiscoverSliderType.TMDB_TV_GENRE ||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE) && (
<GenreTag
type={
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE
? 'movie'
: 'tv'
}
genreId={Number(slider.data)}
/>
)}
{slider.type === DiscoverSliderType.TMDB_SEARCH && (
<Tag iconSvg={<MagnifyingGlassIcon />}>{slider.data}</Tag>
)}
</div>
<div className="flex items-center space-x-2">
{!slider.isBuiltIn && (
<>
{!isEditing ? (
<Button
buttonType="warning"
buttonSize="sm"
onClick={() => {
setIsEditing(true);
}}
>
<PencilIcon />
<span>{intl.formatMessage(globalMessages.edit)}</span>
</Button>
) : (
<Button
buttonType="default"
buttonSize="sm"
onClick={() => {
setIsEditing(false);
}}
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
)}
<Button
data-testid="discover-slider-remove-button"
buttonType="danger"
buttonSize="sm"
onClick={() => {
deleteSlider();
}}
>
<XMarkIcon />
<span>{intl.formatMessage(messages.remove)}</span>
</Button>
</>
)}
<div className="absolute right-14 top-4 flex px-2 md:relative md:top-0 md:right-0">
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Above, true)
}
disabled={disableUpButton}
>
<ChevronUpIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
<button
className={'hover:text-white disabled:text-gray-800'}
onClick={() =>
onPositionUpdate(Number(slider.id), Position.Below, true)
}
disabled={disableDownButton}
>
<ChevronDownIcon className="h-7 w-7 md:h-6 md:w-6" />
</button>
</div>
<div className="absolute top-4 right-4 flex-1 text-right md:relative md:top-0 md:right-0">
<Tooltip content={intl.formatMessage(messages.enable)}>
<div>
<SlideCheckbox
onClick={() => {
onEnable();
}}
checked={slider.enabled}
/>
</div>
</Tooltip>
</div>
</div>
</div>
{isEditing ? (
<div className="p-4">
<CreateSlider
onCreate={() => {
onDelete();
setIsEditing(false);
}}
slider={slider}
/>
</div>
) : (
<div className={`-mt-6 p-4 ${!slider.enabled ? 'opacity-50' : ''}`}>
{children}
</div>
)}
</div>
);
};
export default DiscoverSliderEdit;

View File

@@ -0,0 +1,145 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import useDiscover from '@app/hooks/useDiscover';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Series',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
sortPopularityAsc: 'Popularity Ascending',
sortPopularityDesc: 'Popularity Descending',
sortFirstAirDateAsc: 'First Air Date Ascending',
sortFirstAirDateDesc: 'First Air Date Descending',
sortTmdbRatingAsc: 'TMDB Rating Ascending',
sortTmdbRatingDesc: 'TMDB Rating Descending',
sortTitleAsc: 'Title (A-Z) Ascending',
sortTitleDesc: 'Title (Z-A) Descending',
});
const SortOptions: Record<string, TMDBSortOptions> = {
PopularityAsc: 'popularity.asc',
PopularityDesc: 'popularity.desc',
FirstAirDateAsc: 'first_air_date.asc',
FirstAirDateDesc: 'first_air_date.desc',
TmdbRatingAsc: 'vote_average.asc',
TmdbRatingDesc: 'vote_average.desc',
TitleAsc: 'original_title.asc',
TitleDesc: 'original_title.desc',
} as const;
const DiscoverTv = () => {
const intl = useIntl();
const router = useRouter();
const [showFilters, setShowFilters] = useState(false);
const preparedFilters = prepareFilterValues(router.query);
const updateQueryParams = useUpdateQueryParams({});
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<TvResult, never, FilterOptions>('/api/v1/discover/tv', {
...preparedFilters,
});
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sortBy"
name="sortBy"
className="rounded-r-only"
value={preparedFilters.sortBy}
onChange={(e) => updateQueryParams('sortBy', e.target.value)}
>
<option value={SortOptions.PopularityDesc}>
{intl.formatMessage(messages.sortPopularityDesc)}
</option>
<option value={SortOptions.PopularityAsc}>
{intl.formatMessage(messages.sortPopularityAsc)}
</option>
<option value={SortOptions.FirstAirDateDesc}>
{intl.formatMessage(messages.sortFirstAirDateDesc)}
</option>
<option value={SortOptions.FirstAirDateAsc}>
{intl.formatMessage(messages.sortFirstAirDateAsc)}
</option>
<option value={SortOptions.TmdbRatingDesc}>
{intl.formatMessage(messages.sortTmdbRatingDesc)}
</option>
<option value={SortOptions.TmdbRatingAsc}>
{intl.formatMessage(messages.sortTmdbRatingAsc)}
</option>
<option value={SortOptions.TitleAsc}>
{intl.formatMessage(messages.sortTitleAsc)}
</option>
<option value={SortOptions.TitleDesc}>
{intl.formatMessage(messages.sortTitleDesc)}
</option>
</select>
</div>
<FilterSlideover
type="tv"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;

View File

@@ -1,16 +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 useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovertv: 'Popular Series',
keywordSeries: '{keywordTitle} Series',
});
const DiscoverTv = () => {
const DiscoverTvKeyword = () => {
const router = useRouter();
const intl = useIntl();
const {
@@ -21,13 +25,25 @@ const DiscoverTv = () => {
titles,
fetchMore,
error,
} = useDiscover<TvResult>('/api/v1/discover/tv');
firstResultData,
} = useDiscover<TvResult, { keywords: TmdbKeyword[] }>(
`/api/v1/discover/tv`,
{
keywords: encodeURIExtraParams(router.query.keywords as string),
}
);
if (error) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovertv);
const title = isLoadingInitialData
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.keywordSeries, {
keywordTitle: firstResultData?.keywords
.map((k) => `${k.name[0].toUpperCase()}${k.name.substring(1)}`)
.join(', '),
});
return (
<>
@@ -38,14 +54,14 @@ const DiscoverTv = () => {
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTv;
export default DiscoverTvKeyword;

View File

@@ -0,0 +1,297 @@
import Button from '@app/components/Common/Button';
import MultiRangeSlider from '@app/components/Common/MultiRangeSlider';
import SlideOver from '@app/components/Common/SlideOver';
import type { FilterOptions } from '@app/components/Discover/constants';
import { countActiveFilters } from '@app/components/Discover/constants';
import LanguageSelector from '@app/components/LanguageSelector';
import {
CompanySelector,
GenreSelector,
KeywordSelector,
WatchProviderSelector,
} from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings';
import {
useBatchUpdateQueryParams,
useUpdateQueryParams,
} from '@app/hooks/useUpdateQueryParams';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { defineMessages, useIntl } from 'react-intl';
import Datepicker from 'react-tailwindcss-datepicker-sct';
const messages = defineMessages({
filters: 'Filters',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
releaseDate: 'Release Date',
firstAirDate: 'First Air Date',
from: 'From',
to: 'To',
studio: 'Studio',
genres: 'Genres',
keywords: 'Keywords',
originalLanguage: 'Original Language',
runtimeText: '{minValue}-{maxValue} minute runtime',
ratingText: 'Ratings between {minValue} and {maxValue}',
clearfilters: 'Clear Active Filters',
tmdbuserscore: 'TMDB User Score',
runtime: 'Runtime',
streamingservices: 'Streaming Services',
});
type FilterSlideoverProps = {
show: boolean;
onClose: () => void;
type: 'movie' | 'tv';
currentFilters: FilterOptions;
};
const FilterSlideover = ({
show,
onClose,
type,
currentFilters,
}: FilterSlideoverProps) => {
const intl = useIntl();
const { currentSettings } = useSettings();
const updateQueryParams = useUpdateQueryParams({});
const batchUpdateQueryParams = useBatchUpdateQueryParams({});
const dateGte =
type === 'movie' ? 'primaryReleaseDateGte' : 'firstAirDateGte';
const dateLte =
type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte';
return (
<SlideOver
show={show}
title={intl.formatMessage(messages.filters)}
subText={intl.formatMessage(messages.activefilters, {
count: countActiveFilters(currentFilters),
})}
onClose={() => onClose()}
>
<div className="flex flex-col space-y-4">
<div>
<div className="mb-2 text-lg font-semibold">
{intl.formatMessage(
type === 'movie' ? messages.releaseDate : messages.firstAirDate
)}
</div>
<div className="relative z-40 flex space-x-2">
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateGte] ?? null,
endDate: currentFilters[dateGte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateGte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="fromdate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateLte] ?? null,
endDate: currentFilters[dateLte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateLte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="todate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
</div>
</div>
{type === 'movie' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.studio)}
</span>
<CompanySelector
defaultValue={currentFilters.studio}
onChange={(value) => {
updateQueryParams('studio', value?.value.toString());
}}
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.genres)}
</span>
<GenreSelector
type={type}
defaultValue={currentFilters.genre}
isMulti
onChange={(value) => {
updateQueryParams('genre', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)}
</span>
<KeywordSelector
defaultValue={currentFilters.keywords}
isMulti
onChange={(value) => {
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>
<LanguageSelector
value={currentFilters.language}
serverValue={currentSettings.originalLanguage}
isUserSettings
setFieldValue={(_key, value) => {
updateQueryParams('language', value);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.runtime)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={400}
onUpdateMin={(min) => {
updateQueryParams(
'withRuntimeGte',
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'withRuntimeLte',
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
? max.toString()
: undefined
);
}}
defaultMaxValue={
currentFilters.withRuntimeLte
? Number(currentFilters.withRuntimeLte)
: undefined
}
defaultMinValue={
currentFilters.withRuntimeGte
? Number(currentFilters.withRuntimeGte)
: undefined
}
subText={intl.formatMessage(messages.runtimeText, {
minValue: currentFilters.withRuntimeGte ?? 0,
maxValue: currentFilters.withRuntimeLte ?? 400,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuserscore)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={1}
max={10}
defaultMaxValue={
currentFilters.voteAverageLte
? Number(currentFilters.voteAverageLte)
: undefined
}
defaultMinValue={
currentFilters.voteAverageGte
? Number(currentFilters.voteAverageGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteAverageGte',
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteAverageLte',
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.ratingText, {
minValue: currentFilters.voteAverageGte ?? 1,
maxValue: currentFilters.voteAverageLte ?? 10,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
<WatchProviderSelector
type={type}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>
<div className="pt-4">
<Button
className="w-full"
disabled={Object.keys(currentFilters).length === 0}
onClick={() => {
const copyCurrent = Object.assign({}, currentFilters);
(
Object.keys(copyCurrent) as (keyof typeof currentFilters)[]
).forEach((k) => {
copyCurrent[k] = undefined;
});
batchUpdateQueryParams(copyCurrent);
onClose();
}}
>
<XCircleIcon />
<span>{intl.formatMessage(messages.clearfilters)}</span>
</Button>
</div>
</div>
</SlideOver>
);
};
export default FilterSlideover;

View File

@@ -1,7 +1,7 @@
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 { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const MovieGenreSlider = () => {
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const MovieGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
url={`/discover/movies?genre=${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

View File

@@ -0,0 +1,79 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { UserType, useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
const PlexWatchlistSlider = () => {
const intl = useIntl();
const { user } = useUser();
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,
});
if (
user?.userType !== UserType.PLEX ||
(watchlistItems &&
watchlistItems.results.length === 0 &&
!user?.settings?.watchlistSyncMovies &&
!user?.settings?.watchlistSyncTv) ||
watchlistError
) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/discover/watchlist">
<a className="slider-title">
<span>{intl.formatMessage(messages.plexwatchlist)}</span>
<ArrowRightCircleIcon />
</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}
/>
))}
/>
</>
);
};
export default PlexWatchlistSlider;

View File

@@ -0,0 +1,49 @@
import { sliderTitles } from '@app/components/Discover/constants';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const RecentRequestsSlider = () => {
const intl = useIntl();
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
if (requests && requests.results.length === 0 && !requestError) {
return null;
}
return (
<>
<div className="slider-header">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(sliderTitles.recentrequests)}</span>
<ArrowRightCircleIcon />
</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 />}
/>
</>
);
};
export default RecentRequestsSlider;

View File

@@ -0,0 +1,53 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
recentlyAdded: 'Recently Added',
});
const RecentlyAddedSlider = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
if (
(media && !media.results.length && !mediaError) ||
!hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], {
type: 'or',
})
) {
return null;
}
return (
<>
<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}
/>
))}
/>
</>
);
};
export default RecentlyAddedSlider;

View File

@@ -1,7 +1,7 @@
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 { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
import React from 'react';
@@ -28,7 +28,7 @@ const TvGenreSlider = () => {
<Link href="/discover/tv/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -43,7 +43,7 @@ const TvGenreSlider = () => {
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
url={`/discover/tv?genre=${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}

View File

@@ -1,3 +1,7 @@
import type { ParsedUrlQuery } from 'querystring';
import { defineMessages } from 'react-intl';
import { z } from 'zod';
type AvailableColors =
| 'black'
| 'red'
@@ -61,3 +65,142 @@ export const genreColorMap: Record<number, [string, string]> = {
10767: colorTones.lightgreen, // Talk
10768: colorTones.darkred, // War & Politics
};
export const sliderTitles = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
moviegenres: 'Movie Genres',
tvgenres: 'Series Genres',
studios: 'Studios',
networks: 'Networks',
tmdbmoviekeyword: 'TMDB Movie Keyword',
tmdbtvkeyword: 'TMDB Series Keyword',
tmdbmoviegenre: 'TMDB Movie Genre',
tmdbtvgenre: 'TMDB Series Genre',
tmdbnetwork: 'TMDB Network',
tmdbstudio: 'TMDB Studio',
tmdbsearch: 'TMDB Search',
});
export const QueryFilterOptions = z.object({
sortBy: z.string().optional(),
primaryReleaseDateGte: z.string().optional(),
primaryReleaseDateLte: z.string().optional(),
firstAirDateGte: z.string().optional(),
firstAirDateLte: z.string().optional(),
studio: z.string().optional(),
genre: z.string().optional(),
keywords: z.string().optional(),
language: z.string().optional(),
withRuntimeGte: z.string().optional(),
withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(),
voteAverageLte: z.string().optional(),
watchRegion: z.string().optional(),
watchProviders: z.string().optional(),
});
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
export const prepareFilterValues = (
inputValues: ParsedUrlQuery
): FilterOptions => {
const filterValues: FilterOptions = {};
const values = QueryFilterOptions.parse(inputValues);
if (values.sortBy) {
filterValues.sortBy = values.sortBy;
}
if (values.primaryReleaseDateGte) {
filterValues.primaryReleaseDateGte = values.primaryReleaseDateGte;
}
if (values.primaryReleaseDateLte) {
filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte;
}
if (values.firstAirDateGte) {
filterValues.firstAirDateGte = values.firstAirDateGte;
}
if (values.firstAirDateLte) {
filterValues.firstAirDateLte = values.firstAirDateLte;
}
if (values.studio) {
filterValues.studio = values.studio;
}
if (values.genre) {
filterValues.genre = values.genre;
}
if (values.keywords) {
filterValues.keywords = values.keywords;
}
if (values.language) {
filterValues.language = values.language;
}
if (values.withRuntimeGte) {
filterValues.withRuntimeGte = values.withRuntimeGte;
}
if (values.withRuntimeLte) {
filterValues.withRuntimeLte = values.withRuntimeLte;
}
if (values.voteAverageGte) {
filterValues.voteAverageGte = values.voteAverageGte;
}
if (values.voteAverageLte) {
filterValues.voteAverageLte = values.voteAverageLte;
}
if (values.watchProviders) {
filterValues.watchProviders = values.watchProviders;
}
if (values.watchRegion) {
filterValues.watchRegion = values.watchRegion;
}
return filterValues;
};
export const countActiveFilters = (filterValues: FilterOptions): number => {
let totalCount = 0;
const clonedFilters = Object.assign({}, filterValues);
if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) {
totalCount += 1;
delete clonedFilters.voteAverageGte;
delete clonedFilters.voteAverageLte;
}
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
totalCount += 1;
delete clonedFilters.withRuntimeGte;
delete clonedFilters.withRuntimeLte;
}
if (clonedFilters.watchProviders) {
totalCount += 1;
delete clonedFilters.watchProviders;
delete clonedFilters.watchRegion;
}
totalCount += Object.keys(clonedFilters).length;
return totalCount;
};

View File

@@ -1,189 +1,430 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import { sliderTitles } from '@app/components/Discover/constants';
import CreateSlider from '@app/components/Discover/CreateSlider';
import DiscoverSliderEdit from '@app/components/Discover/DiscoverSliderEdit';
import MovieGenreSlider from '@app/components/Discover/MovieGenreSlider';
import NetworkSlider from '@app/components/Discover/NetworkSlider';
import PlexWatchlistSlider from '@app/components/Discover/PlexWatchlistSlider';
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
import RecentRequestsSlider from '@app/components/Discover/RecentRequestsSlider';
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 { encodeURIExtraParams } from '@app/hooks/useDiscover';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import {
ArrowDownOnSquareIcon,
ArrowPathIcon,
ArrowUturnLeftIcon,
PencilIcon,
PlusIcon,
} from '@heroicons/react/24/solid';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages({
discover: 'Discover',
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
upcoming: 'Upcoming Movies',
trending: 'Trending',
plexwatchlist: 'Your Plex Watchlist',
emptywatchlist:
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
resettodefault: 'Reset to Default',
resetwarning:
'Reset all sliders to default. This will also delete any custom sliders!',
updatesuccess: 'Updated discover customization settings.',
updatefailed:
'Something went wrong updating the discover customization settings.',
resetsuccess: 'Sucessfully reset discover customization settings.',
resetfailed:
'Something went wrong resetting the discover customization settings.',
customizediscover: 'Customize Discover',
stopediting: 'Stop Editing',
createnewslider: 'Create New Slider',
});
const Discover = () => {
const intl = useIntl();
const { user, hasPermission } = useUser();
const { hasPermission } = useUser();
const { addToast } = useToasts();
const {
data: discoverData,
error: discoverError,
mutate,
} = useSWR<DiscoverSlider[]>('/api/v1/settings/discover');
const [sliders, setSliders] = useState<Partial<DiscoverSlider>[]>([]);
const [isEditing, setIsEditing] = useState(false);
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
// We need to sync the state here so that we can modify the changes locally without commiting
// anything to the server until the user decides to save the changes
useEffect(() => {
if (discoverData && !isEditing) {
setSliders(discoverData);
}
}, [discoverData, isEditing]);
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{
revalidateOnMount: true,
}
);
const hasChanged = () => !Object.is(discoverData, sliders);
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,
});
const updateSliders = async () => {
try {
await axios.post('/api/v1/settings/discover', sliders);
addToast(intl.formatMessage(messages.updatesuccess), {
appearance: 'success',
autoDismiss: true,
});
setIsEditing(false);
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.updatefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const resetSliders = async () => {
try {
await axios.get('/api/v1/settings/discover/reset');
addToast(intl.formatMessage(messages.resetsuccess), {
appearance: 'success',
autoDismiss: true,
});
setIsEditing(false);
mutate();
} catch (e) {
addToast(intl.formatMessage(messages.resetfailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const now = new Date();
const offset = now.getTimezoneOffset();
const upcomingDate = new Date(now.getTime() - offset * 60 * 1000)
.toISOString()
.split('T')[0];
if (!discoverData && !discoverError) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.discover)} />
{(!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>
{hasPermission(Permission.ADMIN) && (
<>
{isEditing && (
<div className="my-6 rounded-lg bg-gray-800">
<div className="flex items-center space-x-2 rounded-t-lg border-t border-l border-r border-gray-800 bg-gray-900 p-4 text-lg font-semibold text-gray-400">
<PlusIcon className="w-6" />
<span data-testid="create-slider-header">
{intl.formatMessage(messages.createnewslider)}
</span>
</div>
<div className="p-4">
<CreateSlider
onCreate={async () => {
const newSliders = await mutate();
if (newSliders) {
setSliders(newSliders);
}
}}
/>
</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 />}
/>
)}
<Transition
show={!isEditing}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute-bottom-shift fixed right-6 z-50 flex items-center sm:bottom-8"
>
<button
onClick={() => setIsEditing(true)}
data-testid="discover-start-editing"
className="h-12 w-12 rounded-full border-2 border-gray-600 bg-gray-700 bg-opacity-90 p-3 text-gray-400 shadow transition-all hover:bg-opacity-100"
>
<PencilIcon className="h-full w-full" />
</button>
</Transition>
<Transition
show={isEditing}
enter="transition transform duration-300"
enterFrom="opacity-0 translate-y-6"
enterTo="opacity-100 translate-y-0"
leave="transition duration-300 transform"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-6"
className="safe-shift-edit-menu fixed right-0 left-0 z-50 flex flex-col items-center justify-end space-x-0 space-y-2 border-t border-gray-700 bg-gray-800 bg-opacity-80 p-4 backdrop-blur sm:bottom-0 sm:flex-row sm:space-y-0 sm:space-x-3"
>
<Button
buttonType="default"
onClick={() => setIsEditing(false)}
className="w-full sm:w-auto"
>
<ArrowUturnLeftIcon />
<span>{intl.formatMessage(messages.stopediting)}</span>
</Button>
<Tooltip content={intl.formatMessage(messages.resetwarning)}>
<ConfirmButton
onClick={() => resetSliders()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full sm:w-auto"
>
<ArrowPathIcon />
<span>{intl.formatMessage(messages.resettodefault)}</span>
</ConfirmButton>
</Tooltip>
<Button
buttonType="primary"
type="submit"
disabled={!hasChanged()}
onClick={() => updateSliders()}
data-testid="discover-customize-submit"
className="w-full sm:w-auto"
>
<ArrowDownOnSquareIcon />
<span>{intl.formatMessage(globalMessages.save)}</span>
</Button>
</Transition>
</>
)}
{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)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(messages.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MovieGenreSlider />
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<StudioSlider />
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<TvGenreSlider />
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
<NetworkSlider />
{(isEditing ? sliders : discoverData)?.map((slider, index) => {
let sliderComponent: React.ReactNode;
switch (slider.type) {
case DiscoverSliderType.RECENTLY_ADDED:
sliderComponent = <RecentlyAddedSlider />;
break;
case DiscoverSliderType.RECENT_REQUESTS:
sliderComponent = <RecentRequestsSlider />;
break;
case DiscoverSliderType.PLEX_WATCHLIST:
sliderComponent = <PlexWatchlistSlider />;
break;
case DiscoverSliderType.TRENDING:
sliderComponent = (
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(sliderTitles.trending)}
url="/api/v1/discover/trending"
linkUrl="/discover/trending"
/>
);
break;
case DiscoverSliderType.POPULAR_MOVIES:
sliderComponent = (
<MediaSlider
sliderKey="popular-movies"
title={intl.formatMessage(sliderTitles.popularmovies)}
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
);
break;
case DiscoverSliderType.MOVIE_GENRES:
sliderComponent = <MovieGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_MOVIES:
sliderComponent = (
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(sliderTitles.upcoming)}
linkUrl={`/discover/movies?primaryReleaseDateGte=${upcomingDate}`}
url="/api/v1/discover/movies"
extraParams={`primaryReleaseDateGte=${upcomingDate}`}
/>
);
break;
case DiscoverSliderType.STUDIOS:
sliderComponent = <StudioSlider />;
break;
case DiscoverSliderType.POPULAR_TV:
sliderComponent = (
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(sliderTitles.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
);
break;
case DiscoverSliderType.TV_GENRES:
sliderComponent = <TvGenreSlider />;
break;
case DiscoverSliderType.UPCOMING_TV:
sliderComponent = (
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(sliderTitles.upcomingtv)}
linkUrl={`/discover/tv?firstAirDateGte=${upcomingDate}`}
url="/api/v1/discover/tv"
extraParams={`firstAirDateGte=${upcomingDate}`}
/>
);
break;
case DiscoverSliderType.NETWORKS:
sliderComponent = <NetworkSlider />;
break;
case DiscoverSliderType.TMDB_MOVIE_KEYWORD:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/movies"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/movies?keywords=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_KEYWORD:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/discover/tv"
extraParams={
slider.data
? `keywords=${encodeURIExtraParams(slider.data)}`
: ''
}
linkUrl={`/discover/tv?keywords=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_MOVIE_GENRE:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies`}
extraParams={`genre=${slider.data}`}
linkUrl={`/discover/movies?genre=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_TV_GENRE:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv`}
extraParams={`genre=${slider.data}`}
linkUrl={`/discover/tv?genre=${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_STUDIO:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/movies/studio/${slider.data}`}
linkUrl={`/discover/movies/studio/${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_NETWORK:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url={`/api/v1/discover/tv/network/${slider.data}`}
linkUrl={`/discover/tv/network/${slider.data}`}
/>
);
break;
case DiscoverSliderType.TMDB_SEARCH:
sliderComponent = (
<MediaSlider
sliderKey={`custom-slider-${slider.id}`}
title={slider.title ?? ''}
url="/api/v1/search"
extraParams={`query=${slider.data}`}
linkUrl={`/search?query=${slider.data}`}
/>
);
break;
}
if (isEditing) {
return (
<DiscoverSliderEdit
key={`discover-slider-${slider.id}-edit`}
slider={slider}
onDelete={async () => {
const newSliders = await mutate();
if (newSliders) {
setSliders(newSliders);
}
}}
onEnable={() => {
const tempSliders = sliders.slice();
tempSliders[index].enabled = !tempSliders[index].enabled;
setSliders(tempSliders);
}}
onPositionUpdate={(updatedItemId, position, hasClickedArrows) => {
const originalPosition = sliders.findIndex(
(item) => item.id === updatedItemId
);
const originalItem = sliders[originalPosition];
const tempSliders = sliders.slice();
tempSliders.splice(originalPosition, 1);
hasClickedArrows
? tempSliders.splice(
position === 'Above' ? index - 1 : index + 1,
0,
originalItem
)
: tempSliders.splice(
position === 'Above' && index > originalPosition
? Math.max(index - 1, 0)
: index,
0,
originalItem
);
setSliders(tempSliders);
}}
disableUpButton={index === 0}
disableDownButton={index === sliders.length - 1}
>
{sliderComponent}
</DiscoverSliderEdit>
);
}
if (!slider.enabled) {
return null;
}
return (
<div key={`discover-slider-${slider.id}`}>{sliderComponent}</div>
);
})}
</>
);
};

View File

@@ -0,0 +1,28 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import useSWR from 'swr';
type GenreTagProps = {
type: 'tv' | 'movie';
genreId: number;
};
const GenreTag = ({ genreId, type }: GenreTagProps) => {
const { data, error } = useSWR<TmdbGenre[]>(`/api/v1/genres/${type}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
const genre = data?.find((genre) => genre.id === genreId);
return <Tag iconSvg={<RectangleStackIcon />}>{genre?.name}</Tag>;
};
export default GenreTag;

View File

@@ -3,10 +3,10 @@ import { issueOptions } from '@app/components/IssueModal/constants';
import { useUser } from '@app/hooks/useUser';
import {
CalendarIcon,
ExclamationIcon,
ExclamationTriangleIcon,
EyeIcon,
UserIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
import type Issue from '@server/entity/Issue';
import Link from 'next/link';
import { useIntl } from 'react-intl';
@@ -31,7 +31,7 @@ const IssueBlock = ({ issue }: IssueBlockProps) => {
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="flex flex-nowrap">
<ExclamationIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<ExclamationTriangleIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(issueOption.name)}
</span>

View File

@@ -2,7 +2,7 @@ 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 { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@@ -104,7 +104,7 @@ const IssueComment = ({
<div>
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open options</span>
<DotsVerticalIcon
<EllipsisVerticalIcon
className="h-5 w-5"
aria-hidden="true"
/>

View File

@@ -2,7 +2,7 @@ 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 { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -46,7 +46,10 @@ const IssueDescription = ({
<div>
<Menu.Button className="flex items-center rounded-full text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100">
<span className="sr-only">Open options</span>
<DotsVerticalIcon className="h-5 w-5" aria-hidden="true" />
<EllipsisVerticalIcon
className="h-5 w-5"
aria-hidden="true"
/>
</Menu.Button>
</div>

View File

@@ -14,12 +14,12 @@ import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { Transition } from '@headlessui/react';
import {
ChatIcon,
ChatBubbleOvalLeftEllipsisIcon,
CheckCircleIcon,
PlayIcon,
ServerIcon,
} from '@heroicons/react/outline';
import { RefreshIcon } from '@heroicons/react/solid';
} from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
@@ -390,26 +390,27 @@ const IssueDetails = () => {
</span>
</Button>
)}
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.mediaUrl4k && (
<Button
as="a"
@@ -505,7 +506,8 @@ const IssueDetails = () => {
className="h-20"
/>
<div className="mt-4 flex items-center justify-end space-x-2">
{hasPermission(Permission.MANAGE_ISSUES) && (
{(hasPermission(Permission.MANAGE_ISSUES) ||
belongsToUser) && (
<>
{issueData.status === IssueStatus.OPEN ? (
<Button
@@ -540,7 +542,7 @@ const IssueDetails = () => {
}
}}
>
<RefreshIcon />
<ArrowPathIcon />
<span>
{intl.formatMessage(
values.message
@@ -559,7 +561,7 @@ const IssueDetails = () => {
!isValid || isSubmitting || !values.message
}
>
<ChatIcon />
<ChatBubbleOvalLeftEllipsisIcon />
<span>
{intl.formatMessage(messages.leavecomment)}
</span>
@@ -698,29 +700,31 @@ const IssueDetails = () => {
</span>
</Button>
)}
{issueData?.media.serviceUrl4k && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl4k &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
</div>
</div>
</div>
<div className="extra-bottom-space" />
</div>
);
};

View File

@@ -4,7 +4,7 @@ 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 { EyeIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import type Issue from '@server/entity/Issue';

View File

@@ -6,11 +6,11 @@ import IssueItem from '@app/components/IssueList/IssueItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import globalMessages from '@app/i18n/globalMessages';
import {
BarsArrowDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
FunnelIcon,
} from '@heroicons/react/24/solid';
import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -98,7 +98,7 @@ const IssueList = () => {
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-6" />
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
@@ -128,7 +128,7 @@ const IssueList = () => {
</div>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<SortDescendingIcon className="h-6 w-6" />
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sort"

View File

@@ -5,7 +5,7 @@ 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 { ArrowCircleRightIcon } from '@heroicons/react/solid';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
@@ -121,7 +121,7 @@ const CreateIssueModal = ({
<Link href={`/issues/${newIssue.data.id}`}>
<Button as="a" className="mt-4">
<span>{intl.formatMessage(messages.toastviewissue)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</Button>
</Link>
</>,

View File

@@ -0,0 +1,24 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import type { Keyword } from '@server/models/common';
import useSWR from 'swr';
type KeywordTagProps = {
keywordId: number;
};
const KeywordTag = ({ keywordId }: KeywordTagProps) => {
const { data, error } = useSWR<Keyword>(`/api/v1/keyword/${keywordId}`);
if (!data && !error) {
return (
<Tag>
<Spinner className="h-4 w-4" />
</Tag>
);
}
return <Tag>{data?.name}</Tag>;
};
export default KeywordTag;

View File

@@ -3,7 +3,7 @@ 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 { LanguageIcon } from '@heroicons/react/24/solid';
import { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -28,7 +28,7 @@ const LanguagePicker = () => {
aria-label="Language Picker"
onClick={() => setDropdownOpen(true)}
>
<TranslateIcon className="h-6 w-6" />
<LanguageIcon className="h-6 w-6" />
</button>
</div>
<Transition

View File

@@ -0,0 +1,210 @@
import { menuMessages } from '@app/components/Layout/Sidebar';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
import { Transition } from '@headlessui/react';
import {
ClockIcon,
CogIcon,
EllipsisHorizontalIcon,
ExclamationTriangleIcon,
FilmIcon,
SparklesIcon,
TvIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import {
ClockIcon as FilledClockIcon,
CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
FilmIcon as FilledFilmIcon,
SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon,
UsersIcon as FilledUsersIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { cloneElement, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
interface MenuLink {
href: string;
svgIcon: JSX.Element;
svgIconSelected: JSX.Element;
content: React.ReactNode;
activeRegExp: RegExp;
as?: string;
requiredPermission?: Permission | Permission[];
permissionType?: 'and' | 'or';
dataTestId?: string;
}
const MobileMenu = () => {
const ref = useRef<HTMLDivElement>(null);
const intl = useIntl();
const [isOpen, setIsOpen] = useState(false);
const { hasPermission } = useUser();
const router = useRouter();
useClickOutside(ref, () => {
setTimeout(() => {
if (isOpen) {
setIsOpen(false);
}
}, 150);
});
const toggle = () => setIsOpen(!isOpen);
const menuLinks: MenuLink[] = [
{
href: '/',
content: intl.formatMessage(menuMessages.dashboard),
svgIcon: <SparklesIcon className="h-6 w-6" />,
svgIconSelected: <FilledSparklesIcon className="h-6 w-6" />,
activeRegExp: /^\/(discover\/?)?$/,
},
{
href: '/discover/movies',
content: intl.formatMessage(menuMessages.browsemovies),
svgIcon: <FilmIcon className="h-6 w-6" />,
svgIconSelected: <FilledFilmIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/movies$/,
},
{
href: '/discover/tv',
content: intl.formatMessage(menuMessages.browsetv),
svgIcon: <TvIcon className="h-6 w-6" />,
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/,
},
{
href: '/requests',
content: intl.formatMessage(menuMessages.requests),
svgIcon: <ClockIcon className="h-6 w-6" />,
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
activeRegExp: /^\/requests/,
},
{
href: '/issues',
content: intl.formatMessage(menuMessages.issues),
svgIcon: <ExclamationTriangleIcon className="h-6 w-6" />,
svgIconSelected: <FilledExclamationTriangleIcon className="h-6 w-6" />,
activeRegExp: /^\/issues/,
requiredPermission: [
Permission.MANAGE_ISSUES,
Permission.CREATE_ISSUES,
Permission.VIEW_ISSUES,
],
permissionType: 'or',
},
{
href: '/users',
content: intl.formatMessage(menuMessages.users),
svgIcon: <UsersIcon className="mr-3 h-6 w-6" />,
svgIconSelected: <FilledUsersIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/users/,
requiredPermission: Permission.MANAGE_USERS,
dataTestId: 'sidebar-menu-users',
},
{
href: '/settings',
content: intl.formatMessage(menuMessages.settings),
svgIcon: <CogIcon className="mr-3 h-6 w-6" />,
svgIconSelected: <FilledCogIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/settings/,
requiredPermission: Permission.ADMIN,
dataTestId: 'sidebar-menu-settings',
},
];
const filteredLinks = menuLinks.filter(
(link) =>
!link.requiredPermission ||
hasPermission(link.requiredPermission, {
type: link.permissionType ?? 'and',
})
);
return (
<div className="fixed bottom-0 left-0 right-0 z-50">
<Transition
show={isOpen}
as="div"
ref={ref}
enter="transition transform duration-500"
enterFrom="opacity-0 translate-y-0"
enterTo="opacity-100 -translate-y-full"
leave="transition duration-500 transform"
leaveFrom="opacity-100 -translate-y-full"
leaveTo="opacity-0 translate-y-0"
className="absolute top-0 left-0 right-0 flex w-full -translate-y-full transform flex-col space-y-6 border-t border-gray-600 bg-gray-900 bg-opacity-90 px-6 py-6 font-semibold text-gray-100 backdrop-blur"
>
{filteredLinks.map((link) => {
const isActive = router.pathname.match(link.activeRegExp);
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex items-center space-x-2 ${
isActive ? 'text-indigo-500' : ''
}`}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setIsOpen(false);
}
}}
onClick={() => setIsOpen(false)}
role="button"
tabIndex={0}
>
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
className: 'h-5 w-5',
})}
<span>{link.content}</span>
</a>
</Link>
);
})}
</Transition>
<div className="padding-bottom-safe border-t border-gray-600 bg-gray-800 bg-opacity-90 backdrop-blur">
<div className="flex h-full items-center justify-between px-6 py-4 text-gray-100">
{filteredLinks.slice(0, 4).map((link) => {
const isActive =
router.pathname.match(link.activeRegExp) && !isOpen;
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
{cloneElement(
isActive ? link.svgIconSelected : link.svgIcon,
{
className: 'h-6 w-6',
}
)}
</a>
</Link>
);
})}
{filteredLinks.length > 4 && (
<button
className={`flex flex-col items-center space-y-1 ${
isOpen ? 'text-indigo-500' : ''
}`}
onClick={() => toggle()}
>
{isOpen ? (
<XMarkIcon className="h-6 w-6" />
) : (
<EllipsisHorizontalIcon className="h-6 w-6" />
)}
</button>
)}
</div>
</div>
</div>
);
};
export default MobileMenu;

View File

@@ -1,4 +1,4 @@
import { BellIcon } from '@heroicons/react/outline';
import { BellIcon } from '@heroicons/react/24/outline';
const Notifications = () => {
return (

View File

@@ -1,6 +1,6 @@
import useSearchInput from '@app/hooks/useSearchInput';
import { XCircleIcon } from '@heroicons/react/outline';
import { SearchIcon } from '@heroicons/react/solid';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
@@ -18,7 +18,7 @@ const SearchInput = () => {
</label>
<div className="relative flex w-full items-center text-white focus-within:text-gray-200">
<div className="pointer-events-none absolute inset-y-0 left-4 flex items-center">
<SearchIcon className="h-5 w-5" />
<MagnifyingGlassIcon className="h-5 w-5" />
</div>
<input
id="search_field"

View File

@@ -6,18 +6,22 @@ import { Transition } from '@headlessui/react';
import {
ClockIcon,
CogIcon,
ExclamationIcon,
ExclamationTriangleIcon,
FilmIcon,
SparklesIcon,
TvIcon,
UsersIcon,
XIcon,
} from '@heroicons/react/outline';
XMarkIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Fragment, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
export const menuMessages = defineMessages({
dashboard: 'Discover',
browsemovies: 'Movies',
browsetv: 'Series',
requests: 'Requests',
issues: 'Issues',
users: 'Users',
@@ -32,7 +36,7 @@ interface SidebarProps {
interface SidebarLinkProps {
href: string;
svgIcon: React.ReactNode;
messagesKey: keyof typeof messages;
messagesKey: keyof typeof menuMessages;
activeRegExp: RegExp;
as?: string;
requiredPermission?: Permission | Permission[];
@@ -45,7 +49,19 @@ const SidebarLinks: SidebarLinkProps[] = [
href: '/',
messagesKey: 'dashboard',
svgIcon: <SparklesIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/(discover\/?(movies|tv)?)?$/,
activeRegExp: /^\/(discover\/?)?$/,
},
{
href: '/discover/movies',
messagesKey: 'browsemovies',
svgIcon: <FilmIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/movies$/,
},
{
href: '/discover/tv',
messagesKey: 'browsetv',
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/,
},
{
href: '/requests',
@@ -57,7 +73,7 @@ const SidebarLinks: SidebarLinkProps[] = [
href: '/issues',
messagesKey: 'issues',
svgIcon: (
<ExclamationIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
activeRegExp: /^\/issues/,
requiredPermission: [
@@ -127,7 +143,7 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
aria-label="Close sidebar"
onClick={() => setClosed()}
>
<XIcon className="h-6 w-6 text-white" />
<XMarkIcon className="h-6 w-6 text-white" />
</button>
</div>
<div
@@ -177,7 +193,7 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
>
{sidebarLink.svgIcon}
{intl.formatMessage(
messages[sidebarLink.messagesKey]
menuMessages[sidebarLink.messagesKey]
)}
</a>
</Link>
@@ -242,7 +258,9 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => {
data-testid={sidebarLink.dataTestId}
>
{sidebarLink.svgIcon}
{intl.formatMessage(messages[sidebarLink.messagesKey])}
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
</a>
</Link>
);

View File

@@ -1,8 +1,11 @@
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 {
ArrowRightOnRectangleIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
@@ -147,7 +150,7 @@ const UserDropdown = () => {
}`}
onClick={() => logout()}
>
<LogoutIcon className="mr-2 inline h-5 w-5" />
<ArrowRightOnRectangleIcon className="mr-2 inline h-5 w-5" />
<span>{intl.formatMessage(messages.signout)}</span>
</a>
)}

View File

@@ -1,9 +1,9 @@
import {
ArrowCircleUpIcon,
ArrowUpCircleIcon,
BeakerIcon,
CodeIcon,
CodeBracketIcon,
ServerIcon,
} from '@heroicons/react/outline';
} from '@heroicons/react/24/outline';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';
@@ -56,7 +56,7 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
}`}
>
{data.commitTag === 'local' ? (
<CodeIcon className="h-6 w-6" />
<CodeBracketIcon className="h-6 w-6" />
) : data.version.startsWith('develop-') ? (
<BeakerIcon className="h-6 w-6" />
) : (
@@ -80,7 +80,7 @@ const VersionStatus = ({ onClick }: VersionStatusProps) => {
)}
</span>
</div>
{data.updateAvailable && <ArrowCircleUpIcon className="h-6 w-6" />}
{data.updateAvailable && <ArrowUpCircleIcon className="h-6 w-6" />}
</a>
</Link>
);

View File

@@ -1,3 +1,4 @@
import MobileMenu from '@app/components/Layout/MobileMenu';
import SearchInput from '@app/components/Layout/SearchInput';
import Sidebar from '@app/components/Layout/Sidebar';
import UserDropdown from '@app/components/Layout/UserDropdown';
@@ -6,8 +7,7 @@ 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 { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -57,6 +57,9 @@ const Layout = ({ children }: LayoutProps) => {
</div>
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="sm:hidden">
<MobileMenu />
</div>
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">
<PullToRefresh />
@@ -69,17 +72,17 @@ const Layout = ({ children }: LayoutProps) => {
WebkitBackdropFilter: isScrolled ? 'blur(5px)' : undefined,
}}
>
<button
className={`px-4 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'
} 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>
<div className="flex flex-1 items-center justify-between pr-4 md:pr-4 md:pl-4">
<div className="flex flex-1 items-center justify-between px-4 md:pr-4 md:pl-4">
<button
className={`mr-2 hidden text-white sm:block ${
isScrolled ? 'opacity-90' : 'opacity-70'
} transition duration-300 focus:outline-none lg:hidden`}
aria-label="Open sidebar"
onClick={() => setSidebarOpen(true)}
data-testid="sidebar-toggle"
>
<Bars3BottomLeftIcon className="h-7 w-7" />
</button>
<button
className={`mr-2 text-white ${
isScrolled ? 'opacity-90' : 'opacity-70'

View File

@@ -1,7 +1,10 @@
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 {
ArrowLeftOnRectangleIcon,
LifebuoyIcon,
} from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
@@ -124,7 +127,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
disabled={isSubmitting || !isValid}
data-testid="local-signin-button"
>
<LoginIcon />
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
@@ -136,7 +139,7 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<SupportIcon />
<LifebuoyIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>

View File

@@ -7,7 +7,7 @@ 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 { XCircleIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import axios from 'axios';
import getConfig from 'next/config';

View File

@@ -7,8 +7,8 @@ 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 { Bars4Icon, ServerIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon, DocumentMinusIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
@@ -302,7 +302,7 @@ const ManageSlideOver = ({
watchData?.data ? 'rounded-t-none' : ''
}`}
>
<ViewListIcon />
<Bars4Icon />
<span>
{intl.formatMessage(messages.opentautulli)}
</span>
@@ -423,7 +423,7 @@ const ManageSlideOver = ({
watchData?.data4k ? 'rounded-t-none' : ''
}`}
>
<ViewListIcon />
<Bars4Icon />
<span>
{intl.formatMessage(messages.opentautulli)}
</span>
@@ -497,7 +497,7 @@ const ManageSlideOver = ({
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<DocumentRemoveIcon />
<DocumentMinusIcon />
<span>
{intl.formatMessage(messages.manageModalClearMedia)}
</span>

View File

@@ -1,5 +1,5 @@
import TitleCard from '@app/components/TitleCard';
import { ArrowCircleRightIcon } from '@heroicons/react/solid';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -94,7 +94,7 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
)}
</div>
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
<ArrowCircleRightIcon className="w-14" />
<ArrowRightCircleIcon className="w-14" />
<div className="mt-2 font-extrabold">
{intl.formatMessage(messages.seemore)}
</div>

View File

@@ -3,7 +3,7 @@ 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 { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type {
MovieResult,
@@ -27,14 +27,18 @@ interface MediaSliderProps {
linkUrl?: string;
sliderKey: string;
hideWhenEmpty?: boolean;
extraParams?: string;
onNewTitles?: (titleCount: number) => void;
}
const MediaSlider = ({
title,
url,
linkUrl,
extraParams,
sliderKey,
hideWhenEmpty = false,
onNewTitles,
}: MediaSliderProps) => {
const settings = useSettings();
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
@@ -43,7 +47,9 @@ const MediaSlider = ({
return null;
}
return `${url}?page=${pageIndex + 1}`;
return `${url}?page=${pageIndex + 1}${
extraParams ? `&${extraParams}` : ''
}`;
},
{
initialSize: 2,
@@ -72,7 +78,13 @@ const MediaSlider = ({
) {
setSize(size + 1);
}
}, [titles, setSize, size, data]);
if (onNewTitles) {
// We aren't reporting all titles. We just want to know if there are any titles
// at all for our purposes.
onNewTitles(titles.length);
}
}, [titles, setSize, size, data, onNewTitles]);
if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) {
return null;
@@ -137,9 +149,9 @@ const MediaSlider = ({
<div className="slider-header">
{linkUrl ? (
<Link href={linkUrl}>
<a className="slider-title">
<span>{title}</span>
<ArrowCircleRightIcon />
<a className="slider-title min-w-0 pr-16">
<span className="truncate">{title}</span>
<ArrowRightCircleIcon />
</a>
</Link>
) : (

View File

@@ -9,6 +9,7 @@ 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 Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import ExternalLinkBlock from '@app/components/ExternalLinkBlock';
import IssueModal from '@app/components/IssueModal';
@@ -26,18 +27,18 @@ import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import {
ArrowCircleRightIcon,
ArrowRightCircleIcon,
CloudIcon,
CogIcon,
ExclamationIcon,
ExclamationTriangleIcon,
FilmIcon,
PlayIcon,
TicketIcon,
} from '@heroicons/react/outline';
} from '@heroicons/react/24/outline';
import {
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import { IssueStatus } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media';
@@ -228,7 +229,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
movieAttributes.push(
data.genres
.map((g) => (
<Link href={`/discover/movies/genre/${g.id}`} key={`genre-${g.id}`}>
<Link href={`/discover/movies?genre=${g.id}`} key={`genre-${g.id}`}>
<a className="hover:underline">{g.name}</a>
</Link>
))
@@ -419,7 +420,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationIcon />
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
@@ -477,12 +478,26 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<Link href={`/movie/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<ArrowCircleRightIcon className="ml-1.5 inline-block h-5 w-5" />
<ArrowRightCircleIcon className="ml-1.5 inline-block h-5 w-5" />
</a>
</Link>
</div>
</>
)}
{data.keywords.length > 0 && (
<div className="mt-6">
{data.keywords.map((keyword) => (
<Link
href={`/discover/movies?keywords=${keyword.id}`}
key={`keyword-id-${keyword.id}`}
>
<a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag>{keyword.name}</Tag>
</a>
</Link>
))}
</div>
)}
</div>
<div className="media-overview-right">
{data.collection && (
@@ -817,7 +832,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="slider-title">
<span>{intl.formatMessage(messages.cast)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -851,7 +866,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
linkUrl={`/movie/${data.id}/similar`}
hideWhenEmpty
/>
<div className="pb-8" />
<div className="extra-bottom-space relative" />
</div>
);
};

View File

@@ -1,5 +1,5 @@
import CachedImage from '@app/components/Common/CachedImage';
import { UserCircleIcon } from '@heroicons/react/solid';
import { UserCircleIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { useState } from 'react';

View File

@@ -1,6 +1,6 @@
import globalMessages from '@app/i18n/globalMessages';
import PlexOAuth from '@app/utils/plex';
import { LoginIcon } from '@heroicons/react/outline';
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@@ -49,7 +49,7 @@ const PlexLoginButton = ({
disabled={loading || isProcessing}
className="plex-button"
>
<LoginIcon />
<ArrowLeftOnRectangleIcon />
<span>
{loading
? intl.formatMessage(globalMessages.loading)

View File

@@ -1,4 +1,4 @@
import { RefreshIcon } from '@heroicons/react/outline';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/router';
import PR from 'pulltorefreshjs';
import { useEffect } from 'react';
@@ -15,7 +15,7 @@ const PullToRefresh = () => {
},
iconArrow: ReactDOMServer.renderToString(
<div className="p-2">
<RefreshIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
iconRefreshing: ReactDOMServer.renderToString(
@@ -23,7 +23,7 @@ const PullToRefresh = () => {
className="animate-spin p-2"
style={{ animationDirection: 'reverse' }}
>
<RefreshIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
</div>
),
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),

View File

@@ -1,6 +1,6 @@
import useSettings from '@app/hooks/useSettings';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid';
import type { Region } from '@server/lib/settings';
import { hasFlag } from 'country-flag-icons';
import 'country-flag-icons/3x2/flags.css';
@@ -18,6 +18,8 @@ interface RegionSelectorProps {
value: string;
name: string;
isUserSetting?: boolean;
disableAll?: boolean;
watchProviders?: boolean;
onChange?: (fieldName: string, region: string) => void;
}
@@ -25,11 +27,15 @@ const RegionSelector = ({
name,
value,
isUserSetting = false,
disableAll = false,
watchProviders = false,
onChange,
}: RegionSelectorProps) => {
const { currentSettings } = useSettings();
const intl = useIntl();
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
const { data: regions } = useSWR<Region[]>(
watchProviders ? '/api/v1/watchproviders/regions' : '/api/v1/regions'
);
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
const allRegion: Region = useMemo(
@@ -70,8 +76,8 @@ const RegionSelector = ({
}, [value, regions, allRegion]);
useEffect(() => {
if (onChange && regions) {
onChange(name, selectedRegion?.iso_3166_1 ?? '');
if (onChange && regions && selectedRegion) {
onChange(name, selectedRegion.iso_3166_1);
}
}, [onChange, selectedRegion, name, regions]);
@@ -166,32 +172,34 @@ const RegionSelector = ({
)}
</Listbox.Option>
)}
<Listbox.Option value={isUserSetting ? allRegion : null}>
{({ selected, active }) => (
<div
className={`${
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
} relative cursor-default select-none py-2 pl-8 pr-4`}
>
<span
{!disableAll && (
<Listbox.Option value={isUserSetting ? allRegion : null}>
{({ selected, active }) => (
<div
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate pl-8`}
active ? 'bg-indigo-600 text-white' : 'text-gray-300'
} relative cursor-default select-none py-2 pl-8 pr-4`}
>
{intl.formatMessage(messages.regionDefault)}
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
selected ? 'font-semibold' : 'font-normal'
} block truncate pl-8`}
>
<CheckIcon className="h-5 w-5" />
{intl.formatMessage(messages.regionDefault)}
</span>
)}
</div>
)}
</Listbox.Option>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<CheckIcon className="h-5 w-5" />
</span>
)}
</div>
)}
</Listbox.Option>
)}
{sortedRegions?.map((region) => (
<Listbox.Option key={region.iso_3166_1} value={region}>
{({ selected, active }) => (

View File

@@ -12,8 +12,8 @@ import {
PencilIcon,
TrashIcon,
UserIcon,
XIcon,
} from '@heroicons/react/solid';
XMarkIcon,
} from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
@@ -149,7 +149,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
onClick={() => updateRequest('decline')}
disabled={isUpdating}
>
<XIcon />
<XMarkIcon />
</Button>
</Tooltip>
<Tooltip content={intl.formatMessage(messages.edit)}>

View File

@@ -3,12 +3,12 @@ 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 { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import {
CheckIcon,
InformationCircleIcon,
XIcon,
} from '@heroicons/react/solid';
XMarkIcon,
} from '@heroicons/react/24/solid';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
@@ -158,7 +158,7 @@ const RequestButton = ({
action: () => {
modifyRequest(activeRequest, 'decline');
},
svg: <XIcon />,
svg: <XMarkIcon />,
}
);
} else if (
@@ -186,7 +186,7 @@ const RequestButton = ({
action: () => {
modifyRequests(activeRequests, 'decline');
},
svg: <XIcon />,
svg: <XMarkIcon />,
}
);
}
@@ -228,7 +228,7 @@ const RequestButton = ({
action: () => {
modifyRequest(active4kRequest, 'decline');
},
svg: <XIcon />,
svg: <XMarkIcon />,
}
);
} else if (
@@ -256,7 +256,7 @@ const RequestButton = ({
action: () => {
modifyRequests(active4kRequests, 'decline');
},
svg: <XIcon />,
svg: <XMarkIcon />,
}
);
}
@@ -282,7 +282,7 @@ const RequestButton = ({
setEditRequest(false);
setShowRequestModal(true);
},
svg: <DownloadIcon />,
svg: <ArrowDownTrayIcon />,
});
} else if (
mediaType === 'tv' &&
@@ -301,7 +301,7 @@ const RequestButton = ({
setEditRequest(false);
setShowRequestModal(true);
},
svg: <DownloadIcon />,
svg: <ArrowDownTrayIcon />,
});
}
@@ -327,7 +327,7 @@ const RequestButton = ({
setEditRequest(false);
setShowRequest4kModal(true);
},
svg: <DownloadIcon />,
svg: <ArrowDownTrayIcon />,
});
} else if (
mediaType === 'tv' &&
@@ -347,7 +347,7 @@ const RequestButton = ({
setEditRequest(false);
setShowRequest4kModal(true);
},
svg: <DownloadIcon />,
svg: <ArrowDownTrayIcon />,
});
}

View File

@@ -9,12 +9,12 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers';
import {
ArrowPathIcon,
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
XMarkIcon,
} from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie';
@@ -441,7 +441,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
disabled={isRetrying}
onClick={() => retryRequest()}
>
<RefreshIcon
<ArrowPathIcon
className={isRetrying ? 'animate-spin' : ''}
style={{ marginRight: '0', animationDirection: 'reverse' }}
/>
@@ -483,7 +483,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="hidden sm:block"
onClick={() => modifyRequest('decline')}
>
<XIcon />
<XMarkIcon />
<span>{intl.formatMessage(globalMessages.decline)}</span>
</Button>
<Tooltip
@@ -495,7 +495,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="sm:hidden"
onClick={() => modifyRequest('decline')}
>
<XIcon />
<XMarkIcon />
</Button>
</Tooltip>
</div>
@@ -540,7 +540,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="hidden sm:block"
onClick={() => deleteRequest()}
>
<XIcon />
<XMarkIcon />
<span>{intl.formatMessage(globalMessages.cancel)}</span>
</Button>
<Tooltip content={intl.formatMessage(messages.cancelrequest)}>
@@ -550,7 +550,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="sm:hidden"
onClick={() => deleteRequest()}
>
<XIcon />
<XMarkIcon />
</Button>
</Tooltip>
</div>

View File

@@ -8,12 +8,12 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import {
ArrowPathIcon,
CheckIcon,
PencilIcon,
RefreshIcon,
TrashIcon,
XIcon,
} from '@heroicons/react/solid';
XMarkIcon,
} from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie';
@@ -601,7 +601,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
disabled={isRetrying}
onClick={() => retryRequest()}
>
<RefreshIcon
<ArrowPathIcon
className={isRetrying ? 'animate-spin' : ''}
style={{ animationDirection: 'reverse' }}
/>
@@ -642,7 +642,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
buttonType="danger"
onClick={() => modifyRequest('decline')}
>
<XIcon />
<XMarkIcon />
<span>{intl.formatMessage(globalMessages.decline)}</span>
</Button>
</span>
@@ -672,7 +672,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<XIcon />
<XMarkIcon />
<span>{intl.formatMessage(messages.cancelRequest)}</span>
</ConfirmButton>
)}

View File

@@ -7,11 +7,11 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import {
BarsArrowDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
FilterIcon,
SortDescendingIcon,
} from '@heroicons/react/solid';
FunnelIcon,
} from '@heroicons/react/24/solid';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useRouter } from 'next/router';
@@ -139,7 +139,7 @@ const RequestList = () => {
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-6" />
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
@@ -181,7 +181,7 @@ const RequestList = () => {
</div>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-gray-100 sm:text-sm">
<SortDescendingIcon className="h-6 w-6" />
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sort"

View File

@@ -5,7 +5,7 @@ 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 { CheckIcon, ChevronDownIcon } from '@heroicons/react/solid';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,

View File

@@ -1,5 +1,5 @@
import ProgressCircle from '@app/components/Common/ProgressCircle';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
import type { QuotaStatus } from '@server/interfaces/api/userInterfaces';
import Link from 'next/link';
import { useState } from 'react';

View File

@@ -232,7 +232,9 @@ const TvRequestModal = ({
const getAllSeasons = (): number[] => {
return (data?.seasons ?? [])
.filter((season) => season.seasonNumber !== 0)
.filter(
(season) => season.seasonNumber !== 0 && season.episodeCount !== 0
)
.map((season) => season.seasonNumber);
};
@@ -555,7 +557,10 @@ const TvRequestModal = ({
</thead>
<tbody className="divide-y divide-gray-700">
{data?.seasons
.filter((season) => season.seasonNumber !== 0)
.filter(
(season) =>
season.seasonNumber !== 0 && season.episodeCount !== 0
)
.map((season) => {
const seasonRequest = getSeasonRequest(
season.seasonNumber

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import { ArrowLeftIcon, MailIcon } from '@heroicons/react/solid';
import { ArrowLeftIcon, EnvelopeIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
@@ -128,7 +128,7 @@ const ResetPassword = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<MailIcon />
<EnvelopeIcon />
<span>
{intl.formatMessage(messages.emailresetlink)}
</span>

View File

@@ -3,7 +3,7 @@ import ImageFader from '@app/components/Common/ImageFader';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import globalMessages from '@app/i18n/globalMessages';
import { SupportIcon } from '@heroicons/react/outline';
import { LifebuoyIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Form, Formik } from 'formik';
import Link from 'next/link';
@@ -168,7 +168,7 @@ const ResetPassword = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SupportIcon />
<LifebuoyIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -0,0 +1,457 @@
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip';
import RegionSelector from '@app/components/RegionSelector';
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
import useSettings from '@app/hooks/useSettings';
import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/20/solid';
import { CheckCircleIcon } from '@heroicons/react/24/solid';
import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type {
Keyword,
ProductionCompany,
WatchProviderDetails,
} from '@server/models/common';
import axios from 'axios';
import { orderBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { MultiValue, SingleValue } from 'react-select';
import AsyncSelect from 'react-select/async';
import useSWR from 'swr';
const messages = defineMessages({
searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
showmore: 'Show More',
showless: 'Show Less',
});
type SingleVal = {
label: string;
value: number;
};
type BaseSelectorMultiProps = {
defaultValue?: string;
isMulti: true;
onChange: (value: MultiValue<SingleVal> | null) => void;
};
type BaseSelectorSingleProps = {
defaultValue?: string;
isMulti?: false;
onChange: (value: SingleValue<SingleVal> | null) => void;
};
export const CompanySelector = ({
defaultValue,
isMulti,
onChange,
}: BaseSelectorSingleProps | BaseSelectorMultiProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
const loadDefaultCompany = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const response = await axios.get<ProductionCompany>(
`/api/v1/studio/${defaultValue}`
);
const studio = response.data;
setDefaultDataValue([
{
label: studio.name ?? '',
value: studio.id ?? 0,
},
]);
};
loadDefaultCompany();
}, [defaultValue]);
const loadCompanyOptions = async (inputValue: string) => {
if (inputValue === '') {
return [];
}
const results = await axios.get<TmdbCompanySearchResponse>(
'/api/v1/search/company',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`company-selector-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
isMulti={isMulti}
defaultValue={defaultDataValue}
defaultOptions
cacheOptions
isClearable
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
loadOptions={loadCompanyOptions}
placeholder={intl.formatMessage(messages.searchStudios)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};
type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
type: 'movie' | 'tv';
};
export const GenreSelector = ({
isMulti,
defaultValue,
onChange,
type,
}: GenreSelectorProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
const loadDefaultGenre = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const genres = defaultValue.split(',');
const response = await axios.get<TmdbGenre[]>(`/api/v1/genres/${type}`);
const genreData = genres
.filter((genre) => response.data.find((gd) => gd.id === Number(genre)))
.map((g) => response.data.find((gd) => gd.id === Number(g)))
.map((g) => ({
label: g?.name ?? '',
value: g?.id ?? 0,
}));
setDefaultDataValue(genreData);
};
loadDefaultGenre();
}, [defaultValue, type]);
const loadGenreOptions = async () => {
const results = await axios.get<GenreSliderItem[]>(
`/api/v1/discover/genreslider/${type}`
);
return results.data.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`genre-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
defaultOptions
cacheOptions
isMulti={isMulti}
loadOptions={loadGenreOptions}
placeholder={intl.formatMessage(messages.searchGenres)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};
export const KeywordSelector = ({
isMulti,
defaultValue,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);
useEffect(() => {
const loadDefaultKeywords = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
};
loadDefaultKeywords();
}, [defaultValue]);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`keyword-select-${defaultDataValue}`}
inputId="data"
isMulti={isMulti}
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
inputValue === ''
? intl.formatMessage(messages.starttyping)
: intl.formatMessage(messages.nooptions)
}
defaultValue={defaultDataValue}
loadOptions={loadKeywordOptions}
placeholder={intl.formatMessage(messages.searchKeywords)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
/>
);
};
type WatchProviderSelectorProps = {
type: 'movie' | 'tv';
region?: string;
activeProviders?: number[];
onChange: (region: string, value: number[]) => void;
};
export const WatchProviderSelector = ({
type,
onChange,
region,
activeProviders,
}: WatchProviderSelectorProps) => {
const intl = useIntl();
const { currentSettings } = useSettings();
const [showMore, setShowMore] = useState(false);
const [watchRegion, setWatchRegion] = useState(
region ? region : currentSettings.region ? currentSettings.region : 'US'
);
const [activeProvider, setActiveProvider] = useState<number[]>(
activeProviders ?? []
);
const { data, isLoading } = useSWR<WatchProviderDetails[]>(
`/api/v1/watchproviders/${
type === 'movie' ? 'movies' : 'tv'
}?watchRegion=${watchRegion}`
);
useEffect(() => {
onChange(watchRegion, activeProvider);
}, [activeProvider, watchRegion, onChange]);
const orderedData = useMemo(() => {
if (!data) {
return [];
}
return orderBy(data, ['display_priority'], ['asc']);
}, [data]);
const toggleProvider = (id: number) => {
if (activeProvider.includes(id)) {
setActiveProvider(activeProvider.filter((p) => p !== id));
} else {
setActiveProvider([...activeProvider, id]);
}
};
const initialProviders = orderedData.slice(0, 24);
const otherProviders = orderedData.slice(24);
return (
<>
<RegionSelector
value={watchRegion}
name="watchRegion"
onChange={(_name, value) => {
if (value !== watchRegion) {
setActiveProvider([]);
}
setWatchRegion(value);
}}
disableAll
watchProviders
/>
{isLoading ? (
<SmallLoadingSpinner />
) : (
<div className="grid">
<div className="grid grid-cols-6 gap-2">
{initialProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
<Tooltip
content={provider.name}
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
}`}
onClick={() => toggleProvider(provider.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleProvider(provider.id);
}
}}
role="button"
tabIndex={0}
>
<CachedImage
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
layout="responsive"
width="100%"
height="100%"
className="rounded-lg"
/>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
</div>
)}
</div>
</Tooltip>
);
})}
</div>
{showMore && otherProviders.length > 0 && (
<div className="relative top-2 grid grid-cols-6 gap-2">
{otherProviders.map((provider) => {
const isActive = activeProvider.includes(provider.id);
return (
<Tooltip
content={provider.name}
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
}`}
onClick={() => toggleProvider(provider.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleProvider(provider.id);
}
}}
role="button"
tabIndex={0}
>
<CachedImage
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
layout="responsive"
width="100%"
height="100%"
className="rounded-lg"
/>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
</div>
)}
</div>
</Tooltip>
);
})}
</div>
)}
{otherProviders.length > 0 && (
<button
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
onClick={() => setShowMore(!showMore)}
>
<div className="h-0.5 flex-1 bg-gray-600" />
{showMore ? (
<>
<ArrowUpIcon className="h-4 w-4" />
<span>{intl.formatMessage(messages.showless)}</span>
<ArrowUpIcon className="h-4 w-4" />
</>
) : (
<>
<ArrowDownIcon className="h-4 w-4" />
<span>{intl.formatMessage(messages.showmore)}</span>
<ArrowDownIcon className="h-4 w-4" />
</>
)}
<div className="h-0.5 flex-1 bg-gray-600" />
</button>
)}
</div>
)}
</>
);
};

View File

@@ -1,4 +1,4 @@
import { ClipboardCopyIcon } from '@heroicons/react/solid';
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
import { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -32,7 +32,7 @@ const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
}}
className="input-action"
>
<ClipboardCopyIcon />
<ClipboardDocumentIcon />
</button>
);
};

View File

@@ -1,4 +1,4 @@
import { CheckIcon, XIcon } from '@heroicons/react/solid';
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid';
interface LibraryItemProps {
isEnabled?: boolean;
@@ -41,7 +41,7 @@ const LibraryItem = ({ isEnabled, name, onToggle }: LibraryItemProps) => {
: 'opacity-100 duration-200 ease-in'
} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity`}
>
<XIcon className="h-3 w-3 text-gray-400" />
<XMarkIcon className="h-3 w-3 text-gray-400" />
</span>
<span
className={`${

View File

@@ -3,7 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -295,7 +295,7 @@ const NotificationsDiscord = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -3,7 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -475,7 +475,7 @@ const NotificationsEmail = () => {
type="submit"
disabled={isSubmitting || !isValid || isTesting}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/solid';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -242,7 +242,7 @@ const NotificationsGotify = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -247,7 +247,7 @@ const NotificationsLunaSea = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -3,7 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -239,7 +239,7 @@ const NotificationsPushbullet = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -272,7 +272,7 @@ const NotificationsPushover = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -2,7 +2,7 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -225,7 +225,7 @@ const NotificationsSlack = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -3,7 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
@@ -321,7 +321,7 @@ const NotificationsTelegram = () => {
type="submit"
disabled={isSubmitting || !isValid || isTesting}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -2,7 +2,7 @@ import Alert from '@app/components/Common/Alert';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useEffect, useState } from 'react';
@@ -149,7 +149,7 @@ const NotificationsWebPush = () => {
type="submit"
disabled={isSubmitting || isTesting}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -2,8 +2,11 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import globalMessages from '@app/i18n/globalMessages';
import { BeakerIcon, SaveIcon } from '@heroicons/react/outline';
import { QuestionMarkCircleIcon, RefreshIcon } from '@heroicons/react/solid';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import {
ArrowPathIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import dynamic from 'next/dynamic';
@@ -291,7 +294,7 @@ const NotificationsWebhook = () => {
}}
className="mr-2"
>
<RefreshIcon />
<ArrowPathIcon />
<span>{intl.formatMessage(messages.resetPayload)}</span>
</Button>
<Link
@@ -359,7 +362,7 @@ const NotificationsWebhook = () => {
(values.enabled && !values.types)
}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -4,7 +4,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import { DocumentTextIcon } from '@heroicons/react/outline';
import { DocumentTextIcon } from '@heroicons/react/24/outline';
import dynamic from 'next/dynamic';
import { Fragment, useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';

View File

@@ -6,7 +6,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import Releases from '@app/components/Settings/SettingsAbout/Releases';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { InformationCircleIcon } from '@heroicons/react/solid';
import { InformationCircleIcon } from '@heroicons/react/24/solid';
import type {
SettingsAboutResponse,
StatusResponse,

View File

@@ -10,8 +10,8 @@ import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import { formatBytes } from '@app/utils/numberHelpers';
import { Transition } from '@headlessui/react';
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/outline';
import { PencilIcon } from '@heroicons/react/solid';
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/react/24/outline';
import { PencilIcon } from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import type {
CacheItem,

View File

@@ -13,13 +13,13 @@ import { Transition } from '@headlessui/react';
import {
ChevronLeftIcon,
ChevronRightIcon,
ClipboardCopyIcon,
DocumentSearchIcon,
FilterIcon,
ClipboardDocumentIcon,
DocumentMagnifyingGlassIcon,
FunnelIcon,
MagnifyingGlassIcon,
PauseIcon,
PlayIcon,
SearchIcon,
} from '@heroicons/react/solid';
} from '@heroicons/react/24/solid';
import type {
LogMessage,
LogsResultsResponse,
@@ -252,7 +252,7 @@ const SettingsLogs = () => {
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<SearchIcon className="h-6 w-6" />
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
@@ -276,7 +276,7 @@ const SettingsLogs = () => {
</Button>
<div className="flex flex-grow">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FilterIcon className="h-6 w-6" />
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
@@ -367,7 +367,7 @@ const SettingsLogs = () => {
}
className="m-1"
>
<DocumentSearchIcon className="icon-md" />
<DocumentMagnifyingGlassIcon className="icon-md" />
</Button>
</Tooltip>
)}
@@ -380,7 +380,7 @@ const SettingsLogs = () => {
onClick={() => copyLogString(row)}
className="m-1"
>
<ClipboardCopyIcon className="icon-md" />
<ClipboardDocumentIcon className="icon-md" />
</Button>
</Tooltip>
</Table.TD>

View File

@@ -12,8 +12,8 @@ import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { RefreshIcon } from '@heroicons/react/solid';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import type { MainSettings } from '@server/lib/settings';
import axios from 'axios';
@@ -185,7 +185,7 @@ const SettingsMain = () => {
setFieldValue,
}) => {
return (
<Form className="section">
<Form className="section" data-testid="settings-main-form">
{userHasPermission(Permission.ADMIN) && (
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
@@ -211,7 +211,7 @@ const SettingsMain = () => {
}}
className="input-action"
>
<RefreshIcon />
<ArrowPathIcon />
</button>
</div>
</div>
@@ -435,7 +435,7 @@ const SettingsMain = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -9,7 +9,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import type { SettingsRoute } from '@app/components/Common/SettingsTabs';
import SettingsTabs from '@app/components/Common/SettingsTabs';
import globalMessages from '@app/i18n/globalMessages';
import { CloudIcon, LightningBoltIcon, MailIcon } from '@heroicons/react/solid';
import { BoltIcon, CloudIcon, EnvelopeIcon } from '@heroicons/react/24/solid';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
@@ -34,7 +34,7 @@ const SettingsNotifications = ({ children }: SettingsNotificationsProps) => {
text: intl.formatMessage(messages.email),
content: (
<span className="flex items-center">
<MailIcon className="mr-2 h-4" />
<EnvelopeIcon className="mr-2 h-4" />
{intl.formatMessage(messages.email)}
</span>
),
@@ -133,7 +133,7 @@ const SettingsNotifications = ({ children }: SettingsNotificationsProps) => {
text: intl.formatMessage(messages.webhook),
content: (
<span className="flex items-center">
<LightningBoltIcon className="mr-2 h-4" />
<BoltIcon className="mr-2 h-4" />
{intl.formatMessage(messages.webhook)}
</span>
),

View File

@@ -7,8 +7,12 @@ import SensitiveInput from '@app/components/Common/SensitiveInput';
import LibraryItem from '@app/components/Settings/LibraryItem';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { RefreshIcon, SearchIcon, XIcon } from '@heroicons/react/solid';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import {
ArrowPathIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import type { PlexSettings, TautulliSettings } from '@server/lib/settings';
import axios from 'axios';
@@ -487,7 +491,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
}}
className="input-action"
>
<RefreshIcon
<ArrowPathIcon
className={isRefreshingPresets ? 'animate-spin' : ''}
style={{ animationDirection: 'reverse' }}
/>
@@ -598,7 +602,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
@@ -625,7 +629,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
onClick={() => syncLibraries()}
disabled={isSyncing || !data?.ip || !data?.port}
>
<RefreshIcon
<ArrowPathIcon
className={isSyncing ? 'animate-spin' : ''}
style={{ animationDirection: 'reverse' }}
/>
@@ -708,12 +712,12 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
onClick={() => startScan()}
disabled={isSyncing || !activeLibraries.length}
>
<SearchIcon />
<MagnifyingGlassIcon />
<span>{intl.formatMessage(messages.startscan)}</span>
</Button>
) : (
<Button buttonType="danger" onClick={() => cancelScan()}>
<XIcon />
<XMarkIcon />
<span>{intl.formatMessage(messages.cancelscan)}</span>
</Button>
)}
@@ -916,7 +920,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -10,7 +10,7 @@ import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/solid';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';

View File

@@ -5,7 +5,7 @@ import PermissionEdit from '@app/components/PermissionEdit';
import QuotaSelector from '@app/components/QuotaSelector';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import { MediaServerType } from '@server/constants/server';
import type { MainSettings } from '@server/lib/settings';
import axios from 'axios';
@@ -224,7 +224,7 @@ const SettingsUsers = () => {
type="submit"
disabled={isSubmitting}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -1,4 +1,4 @@
import { CheckIcon } from '@heroicons/react/solid';
import { CheckIcon } from '@heroicons/react/24/solid';
interface CurrentStep {
stepNumber: number;

View File

@@ -1,6 +1,6 @@
import TitleCard from '@app/components/TitleCard';
import globalMessages from '@app/i18n/globalMessages';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { debounce } from 'lodash';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';

View File

@@ -126,7 +126,7 @@ const StatusBadge = ({
const badgeDownloadProgress = (
<div
className={`
absolute top-0 left-0 z-10 flex h-full ${
absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
} transition-all duration-200 ease-in-out
`}

View File

@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
import globalMessages from '@app/i18n/globalMessages';
import { CheckIcon, TrashIcon } from '@heroicons/react/solid';
import { CheckIcon, TrashIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { defineMessages, useIntl } from 'react-intl';
import { mutate } from 'swr';

View File

@@ -1,6 +1,7 @@
import Spinner from '@app/assets/spinner.svg';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import StatusBadgeMini from '@app/components/Common/StatusBadgeMini';
import RequestModal from '@app/components/RequestModal';
import ErrorCard from '@app/components/TitleCard/ErrorCard';
import Placeholder from '@app/components/TitleCard/Placeholder';
@@ -9,8 +10,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { DownloadIcon } from '@heroicons/react/outline';
import { BellIcon, CheckIcon, ClockIcon } from '@heroicons/react/solid';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { MediaStatus } from '@server/constants/media';
import type { MediaType } from '@server/models/Search';
import Link from 'next/link';
@@ -129,8 +129,10 @@ const TitleCard = ({
/>
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 rounded-full shadow ${
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
mediaType === 'movie'
? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600'
}`}
>
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
@@ -139,28 +141,15 @@ const TitleCard = ({
: intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
<div className="pointer-events-none z-40">
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-green-400 text-white shadow sm:h-5 sm:w-5">
<CheckIcon className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
)}
{currentStatus === MediaStatus.PENDING && (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-white shadow sm:h-5 sm:w-5">
<BellIcon className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
)}
{currentStatus === MediaStatus.PROCESSING && (
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-500 text-white shadow sm:h-5 sm:w-5">
{inProgress ? (
<Spinner className="h-3 w-3" />
) : (
<ClockIcon className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</div>
)}
</div>
{currentStatus && (
<div className="pointer-events-none z-40 flex items-center">
<StatusBadgeMini
status={currentStatus}
inProgress={inProgress}
shrink
/>
</div>
)}
</div>
<Transition
as={Fragment}
@@ -256,7 +245,7 @@ const TitleCard = ({
}}
className="h-7 w-full"
>
<DownloadIcon />
<ArrowDownTrayIcon />
<span>{intl.formatMessage(globalMessages.request)}</span>
</Button>
)}

View File

@@ -2,10 +2,10 @@ import { Transition } from '@headlessui/react';
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from '@heroicons/react/outline';
import { XIcon } from '@heroicons/react/solid';
} from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/solid';
import { Fragment } from 'react';
import type { ToastProps } from 'react-toast-notifications';
@@ -42,7 +42,7 @@ const Toast = ({
<InformationCircleIcon className="h-6 w-6 text-indigo-500" />
)}
{appearance === 'warning' && (
<ExclamationIcon className="h-6 w-6 text-orange-400" />
<ExclamationTriangleIcon className="h-6 w-6 text-orange-400" />
)}
</div>
<div className="ml-3 w-0 flex-1 text-white">{children}</div>
@@ -51,7 +51,7 @@ const Toast = ({
onClick={() => onDismiss()}
className="inline-flex text-gray-400 transition duration-150 ease-in-out focus:text-gray-500 focus:outline-none"
>
<XIcon className="h-5 w-5" />
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>

View File

@@ -44,7 +44,9 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
>
<div className="flex-1">
<div className="flex flex-col space-y-2 xl:flex-row xl:items-center xl:space-y-0 xl:space-x-2">
<h3 className="text-lg">{episode.name}</h3>
<h3 className="text-lg">
{episode.episodeNumber} - {episode.name}
</h3>
{episode.airDate && (
<AirDateBadge airDate={episode.airDate} />
)}

View File

@@ -11,6 +11,7 @@ import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import StatusBadgeMini from '@app/components/Common/StatusBadgeMini';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import ExternalLinkBlock from '@app/components/ExternalLinkBlock';
import IssueModal from '@app/components/IssueModal';
@@ -31,13 +32,13 @@ import Error from '@app/pages/_error';
import { sortCrewPriority } from '@app/utils/creditHelpers';
import { Disclosure, Transition } from '@headlessui/react';
import {
ArrowCircleRightIcon,
ArrowRightCircleIcon,
CogIcon,
ExclamationIcon,
ExclamationTriangleIcon,
FilmIcon,
PlayIcon,
} from '@heroicons/react/outline';
import { ChevronUpIcon } from '@heroicons/react/solid';
} from '@heroicons/react/24/outline';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
@@ -200,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
}
const seasonCount = data.seasons.filter(
(season) => season.seasonNumber !== 0
(season) => season.seasonNumber !== 0 && season.episodeCount !== 0
).length;
if (seasonCount) {
@@ -213,7 +214,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
seriesAttributes.push(
data.genres
.map((g) => (
<Link href={`/discover/tv/genre/${g.id}`} key={`genre-${g.id}`}>
<Link href={`/discover/tv?genre=${g.id}`} key={`genre-${g.id}`}>
<a className="hover:underline">{g.name}</a>
</Link>
))
@@ -228,25 +229,37 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
);
}
const isComplete =
seasonCount <=
(
data.mediaInfo?.seasons.filter(
(season) =>
season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE
) ?? []
).length;
const getAllRequestedSeasons = (is4k: boolean): number[] => {
const requestedSeasons = (data?.mediaInfo?.requests ?? [])
.filter(
(request) =>
request.is4k === is4k &&
request.status !== MediaRequestStatus.DECLINED
)
.reduce((requestedSeasons, request) => {
return [
...requestedSeasons,
...request.seasons.map((sr) => sr.seasonNumber),
];
}, [] as number[]);
const is4kComplete =
seasonCount <=
(
data.mediaInfo?.seasons.filter(
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
.filter(
(season) =>
season.status4k === MediaStatus.AVAILABLE ||
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
) ?? []
).length;
(season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE ||
season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) &&
!requestedSeasons.includes(season.seasonNumber)
)
.map((season) => season.seasonNumber);
return [...requestedSeasons, ...availableSeasons];
};
const isComplete = seasonCount <= getAllRequestedSeasons(false).length;
const is4kComplete = seasonCount <= getAllRequestedSeasons(true).length;
const streamingProviders =
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
@@ -436,7 +449,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationIcon />
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
@@ -508,12 +521,26 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
<Link href={`/tv/${data.id}/crew`}>
<a className="flex items-center text-gray-400 transition duration-300 hover:text-gray-100">
<span>{intl.formatMessage(messages.viewfullcrew)}</span>
<ArrowCircleRightIcon className="ml-1.5 inline-block h-5 w-5" />
<ArrowRightCircleIcon className="ml-1.5 inline-block h-5 w-5" />
</a>
</Link>
</div>
</>
)}
{data.keywords.length > 0 && (
<div className="mt-6">
{data.keywords.map((keyword) => (
<Link
href={`/discover/tv?keywords=${keyword.id}`}
key={`keyword-id-${keyword.id}`}
>
<a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag>{keyword.name}</Tag>
</a>
</Link>
))}
</div>
)}
<h2 className="py-4">{intl.formatMessage(messages.seasonstitle)}</h2>
<div className="flex w-full flex-col space-y-2">
{data.seasons
@@ -556,6 +583,10 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
) && r.is4k
);
if (season.episodeCount === 0) {
return null;
}
return (
<Disclosure key={`season-discoslure-${season.seasonNumber}`}>
{({ open }) => (
@@ -726,7 +757,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</div>
</>
)}
<ChevronUpIcon
<ChevronDownIcon
className={`${
open ? 'rotate-180 transform' : ''
} h-6 w-6 text-gray-500`}
@@ -816,12 +847,13 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
)}
</div>
)}
{data.originalName && data.originalLanguage !== locale.slice(0, 2) && (
<div className="media-fact">
<span>{intl.formatMessage(messages.originaltitle)}</span>
<span className="media-fact-value">{data.originalName}</span>
</div>
)}
{data.originalName &&
data.originalLanguage !== locale.slice(0, 2) && (
<div className="media-fact">
<span>{intl.formatMessage(messages.originaltitle)}</span>
<span className="media-fact-value">{data.originalName}</span>
</div>
)}
{data.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && (
@@ -982,7 +1014,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
<Link href="/tv/[tvId]/cast" as={`/tv/${data.id}/cast`}>
<a className="slider-title">
<span>{intl.formatMessage(messages.cast)}</span>
<ArrowCircleRightIcon />
<ArrowRightCircleIcon />
</a>
</Link>
</div>
@@ -1016,7 +1048,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
linkUrl={`/tv/${data.id}/similar`}
hideWhenEmpty
/>
<div className="pb-8" />
<div className="extra-bottom-space relative" />
</div>
);
};

View File

@@ -16,13 +16,13 @@ import { Permission, UserType, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import {
BarsArrowDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
InboxInIcon,
InboxArrowDownIcon,
PencilIcon,
SortDescendingIcon,
UserAddIcon,
} from '@heroicons/react/solid';
UserPlusIcon,
} from '@heroicons/react/24/solid';
import { MediaServerType } from '@server/constants/server';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions';
@@ -504,7 +504,7 @@ const UserList = () => {
buttonType="primary"
onClick={() => setCreateModal({ isOpen: true })}
>
<UserAddIcon />
<UserPlusIcon />
<span>{intl.formatMessage(messages.createlocaluser)}</span>
</Button>
<Button
@@ -512,7 +512,7 @@ const UserList = () => {
buttonType="primary"
onClick={() => setShowImportModal(true)}
>
<InboxInIcon />
<InboxArrowDownIcon />
<span>
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.importfrommediaserver, {
@@ -531,7 +531,7 @@ const UserList = () => {
</div>
<div className="mb-2 flex flex-grow lg:mb-0 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<SortDescendingIcon className="h-6 w-6" />
<BarsArrowDownIcon className="h-6 w-6" />
</span>
<select
id="sort"

View File

@@ -1,7 +1,7 @@
import Button from '@app/components/Common/Button';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import { CogIcon, UserIcon } from '@heroicons/react/solid';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { defineMessages, useIntl } from 'react-intl';

View File

@@ -12,7 +12,7 @@ import useSettings from '@app/hooks/useSettings';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@@ -566,7 +566,7 @@ const UserGeneralSettings = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -3,7 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@@ -156,7 +156,7 @@ const UserNotificationsDiscord = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -8,7 +8,7 @@ import { OpenPgpLink } from '@app/components/Settings/Notifications/Notification
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Form, Formik } from 'formik';
@@ -151,7 +151,7 @@ const UserEmailSettings = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -3,7 +3,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@@ -185,7 +185,7 @@ const UserTelegramSettings = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

View File

@@ -5,7 +5,7 @@ import NotificationTypeSelector, {
} from '@app/components/NotificationTypeSelector';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { SaveIcon } from '@heroicons/react/outline';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Form, Formik } from 'formik';
@@ -103,7 +103,7 @@ const UserWebPushSettings = () => {
type="submit"
disabled={isSubmitting || !isValid}
>
<SaveIcon />
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

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