mirror of
https://github.com/sct/overseerr.git
synced 2025-09-30 05:54:03 +02:00
feat: PWA Support (#1488)
This commit is contained in:
@@ -3,14 +3,13 @@ import axios from 'axios';
|
||||
import { uniq } from 'lodash';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import type { Collection } from '../../../server/models/Collection';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
@@ -48,14 +47,13 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { addToast } = useToasts();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { hasPermission } = useUser();
|
||||
const [requestModal, setRequestModal] = useState(false);
|
||||
const [isRequesting, setRequesting] = useState(false);
|
||||
const [is4k, setIs4k] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<Collection>(
|
||||
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
|
||||
`/api/v1/collection/${router.query.collectionId}`,
|
||||
{
|
||||
initialData: collection,
|
||||
revalidateOnMount: true,
|
||||
@@ -63,7 +61,7 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||
);
|
||||
|
||||
const { data: genres } = useSWR<{ id: number; name: string }[]>(
|
||||
`/api/v1/genres/movie?language=${locale}`
|
||||
`/api/v1/genres/movie`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
|
@@ -1,24 +1,22 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { genreColorMap } from '../constants';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Error from '../../../pages/_error';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { genreColorMap } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
moviegenres: 'Movie Genres',
|
||||
});
|
||||
|
||||
const MovieGenreList: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/movie?language=${locale}`
|
||||
`/api/v1/discover/genreslider/movie`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import Slider from '../../Slider';
|
||||
import { genreColorMap } from '../constants';
|
||||
@@ -14,10 +13,9 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const MovieGenreSlider: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/movie?language=${locale}`,
|
||||
`/api/v1/discover/genreslider/movie`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
|
@@ -1,24 +1,22 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { genreColorMap } from '../constants';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import Error from '../../../pages/_error';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import { genreColorMap } from '../constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
seriesgenres: 'Series Genres',
|
||||
});
|
||||
|
||||
const TvGenreList: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/tv?language=${locale}`
|
||||
`/api/v1/discover/genreslider/tv`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import GenreCard from '../../GenreCard';
|
||||
import Slider from '../../Slider';
|
||||
import { genreColorMap } from '../constants';
|
||||
@@ -14,10 +13,9 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
const TvGenreSlider: React.FC = () => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/tv?language=${locale}`,
|
||||
`/api/v1/discover/genreslider/tv`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
revalidateOnFocus: false,
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { MediaType } from '../../../server/constants/media';
|
||||
import ImdbLogo from '../../assets/services/imdb.svg';
|
||||
import PlexLogo from '../../assets/services/plex.svg';
|
||||
import RTLogo from '../../assets/services/rt.svg';
|
||||
import TmdbLogo from '../../assets/services/tmdb.svg';
|
||||
import TvdbLogo from '../../assets/services/tvdb.svg';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
@@ -24,7 +24,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
|
||||
rtUrl,
|
||||
plexUrl,
|
||||
}) => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { locale } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full space-x-5">
|
||||
|
@@ -1,93 +1,22 @@
|
||||
import { TranslateIcon } from '@heroicons/react/solid';
|
||||
import React, { useContext, useRef, useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
availableLanguages,
|
||||
AvailableLocales,
|
||||
LanguageContext,
|
||||
} from '../../../context/LanguageContext';
|
||||
import useClickOutside from '../../../hooks/useClickOutside';
|
||||
import useLocale from '../../../hooks/useLocale';
|
||||
import Transition from '../../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
changelanguage: 'Change Language',
|
||||
});
|
||||
|
||||
type AvailableLanguageObject = Record<
|
||||
string,
|
||||
{ code: AvailableLocales; display: string }
|
||||
>;
|
||||
|
||||
const availableLanguages: AvailableLanguageObject = {
|
||||
ca: {
|
||||
code: 'ca',
|
||||
display: 'Català',
|
||||
},
|
||||
de: {
|
||||
code: 'de',
|
||||
display: 'Deutsch',
|
||||
},
|
||||
en: {
|
||||
code: 'en',
|
||||
display: 'English',
|
||||
},
|
||||
es: {
|
||||
code: 'es',
|
||||
display: 'Español',
|
||||
},
|
||||
fr: {
|
||||
code: 'fr',
|
||||
display: 'Français',
|
||||
},
|
||||
it: {
|
||||
code: 'it',
|
||||
display: 'Italiano',
|
||||
},
|
||||
hu: {
|
||||
code: 'hu',
|
||||
display: 'Magyar',
|
||||
},
|
||||
nl: {
|
||||
code: 'nl',
|
||||
display: 'Nederlands',
|
||||
},
|
||||
'nb-NO': {
|
||||
code: 'nb-NO',
|
||||
display: 'Norsk Bokmål',
|
||||
},
|
||||
'pt-BR': {
|
||||
code: 'pt-BR',
|
||||
display: 'Português (Brasil)',
|
||||
},
|
||||
'pt-PT': {
|
||||
code: 'pt-PT',
|
||||
display: 'Português (Portugal)',
|
||||
},
|
||||
sv: {
|
||||
code: 'sv',
|
||||
display: 'Svenska',
|
||||
},
|
||||
ru: {
|
||||
code: 'ru',
|
||||
display: 'pусский',
|
||||
},
|
||||
sr: {
|
||||
code: 'sr',
|
||||
display: 'српски језик',
|
||||
},
|
||||
ja: {
|
||||
code: 'ja',
|
||||
display: '日本語',
|
||||
},
|
||||
'zh-TW': {
|
||||
code: 'zh-TW',
|
||||
display: '中文(臺灣)',
|
||||
},
|
||||
};
|
||||
|
||||
const LanguagePicker: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { locale, setLocale } = useContext(LanguageContext);
|
||||
const { locale, setLocale } = useLocale();
|
||||
const [isDropdownOpen, setDropdownOpen] = useState(false);
|
||||
useClickOutside(dropdownRef, () => setDropdownOpen(false));
|
||||
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import { MenuAlt2Icon } from '@heroicons/react/outline';
|
||||
import { InformationCircleIcon } from '@heroicons/react/solid';
|
||||
import { ArrowLeftIcon, InformationCircleIcon } from '@heroicons/react/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import SearchInput from './SearchInput';
|
||||
import Sidebar from './Sidebar';
|
||||
import UserDropdown from './UserDropdown';
|
||||
@@ -23,7 +22,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const updateScrolled = () => {
|
||||
if (window.pageYOffset > 60) {
|
||||
if (window.pageYOffset > 20) {
|
||||
setIsScrolled(true);
|
||||
} else {
|
||||
setIsScrolled(false);
|
||||
@@ -55,16 +54,25 @@ const Layout: React.FC = ({ children }) => {
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="px-4 text-gray-200 focus:outline-none focus:bg-gray-300 focus:text-gray-600 md:hidden"
|
||||
className={`px-4 ${
|
||||
isScrolled ? 'text-gray-200' : 'text-gray-400'
|
||||
} focus:outline-none md:hidden transition duration-300`}
|
||||
aria-label="Open sidebar"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<MenuAlt2Icon className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex justify-between flex-1 pr-4 md:pr-4 md:pl-4">
|
||||
<div className="flex items-center justify-between flex-1 pr-4 md:pr-4 md:pl-4">
|
||||
<button
|
||||
className={`mr-2 ${
|
||||
isScrolled ? 'text-gray-200' : 'text-gray-400'
|
||||
} transition duration-300 hover:text-white pwa-only focus:outline-none focus:text-white`}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeftIcon className="w-7" />
|
||||
</button>
|
||||
<SearchInput />
|
||||
<div className="flex items-center ml-2">
|
||||
<LanguagePicker />
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { ArrowCircleRightIcon } from '@heroicons/react/outline';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import type {
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from '../../../server/models/Search';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import PersonCard from '../PersonCard';
|
||||
import Slider from '../Slider';
|
||||
@@ -38,14 +37,13 @@ const MediaSlider: React.FC<MediaSliderProps> = ({
|
||||
hideWhenEmpty = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, setSize, size } = useSWRInfinite<MixedResult>(
|
||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${url}?page=${pageIndex + 1}&language=${locale}`;
|
||||
return `${url}?page=${pageIndex + 1}`;
|
||||
},
|
||||
{
|
||||
initialSize: 2,
|
||||
|
@@ -1,15 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullcast: 'Full Cast',
|
||||
@@ -18,9 +17,8 @@ const messages = defineMessages({
|
||||
const MovieCast: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
|
@@ -1,15 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import { MovieDetails } from '../../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullcrew: 'Full Crew',
|
||||
@@ -18,9 +17,8 @@ const messages = defineMessages({
|
||||
const MovieCrew: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
|
@@ -1,16 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
import Link from 'next/link';
|
||||
import Header from '../Common/Header';
|
||||
import ListView from '../Common/ListView';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
@@ -19,9 +18,8 @@ const messages = defineMessages({
|
||||
const MovieRecommendations: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const router = useRouter();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
|
@@ -1,16 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Header from '../Common/Header';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useSWR from 'swr';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import type { MovieResult } from '../../../server/models/Search';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
import Link from 'next/link';
|
||||
import Header from '../Common/Header';
|
||||
import ListView from '../Common/ListView';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
similar: 'Similar Titles',
|
||||
@@ -19,9 +18,8 @@ const messages = defineMessages({
|
||||
const MovieSimilar: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: movieData } = useSWR<MovieDetails>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`
|
||||
`/api/v1/movie/${router.query.movieId}`
|
||||
);
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
|
@@ -12,7 +12,7 @@ import {
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
@@ -23,7 +23,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||
import RTFresh from '../../assets/rt_fresh.svg';
|
||||
import RTRotten from '../../assets/rt_rotten.svg';
|
||||
import TmdbLogo from '../../assets/tmdb_logo.svg';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
@@ -84,11 +84,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
const { user, hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { locale } = useLocale();
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<MovieDetailsType>(
|
||||
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
|
||||
`/api/v1/movie/${router.query.movieId}`,
|
||||
{
|
||||
initialData: movie,
|
||||
}
|
||||
|
@@ -53,6 +53,10 @@ export enum Notification {
|
||||
MEDIA_AUTO_APPROVED = 128,
|
||||
}
|
||||
|
||||
export const ALL_NOTIFICATIONS = Object.values(Notification)
|
||||
.filter((v) => !isNaN(Number(v)))
|
||||
.reduce((a, v) => a + Number(v), 0);
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
|
183
src/components/PWAHeader/index.tsx
Normal file
183
src/components/PWAHeader/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PWAHeaderProps {
|
||||
applicationTitle?: string;
|
||||
}
|
||||
|
||||
const PWAHeader: React.FC<PWAHeaderProps> = ({ applicationTitle }) => {
|
||||
return (
|
||||
<>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2048-2732.jpg"
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2732-2048.jpg"
|
||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1668-2388.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2388-1668.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1536-2048.jpg"
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2048-1536.jpg"
|
||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1668-2224.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2224-1668.jpg"
|
||||
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1620-2160.jpg"
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2160-1620.jpg"
|
||||
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1284-2778.jpg"
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2778-1284.jpg"
|
||||
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1170-2532.jpg"
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2532-1170.jpg"
|
||||
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1125-2436.jpg"
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2436-1125.jpg"
|
||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1242-2688.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2688-1242.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-828-1792.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1792-828.jpg"
|
||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1242-2208.jpg"
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-2208-1242.jpg"
|
||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-750-1334.jpg"
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1334-750.jpg"
|
||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-640-1136.jpg"
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/apple-splash-1136-640.jpg"
|
||||
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/site.webmanifest"
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
<meta name="application-name" content={applicationTitle ?? 'Overseerr'} />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content={applicationTitle ?? 'Overseerr'}
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Request and Media Discovery Application"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PWAHeader;
|
@@ -1,13 +1,12 @@
|
||||
import { groupBy } from 'lodash';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import TruncateMarkup from 'react-truncate-markup';
|
||||
import useSWR from 'swr';
|
||||
import type { PersonCombinedCreditsResponse } from '../../../server/interfaces/api/personInterfaces';
|
||||
import type { PersonDetail } from '../../../server/models/Person';
|
||||
import Ellipsis from '../../assets/ellipsis.svg';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Error from '../../pages/_error';
|
||||
import CachedImage from '../Common/CachedImage';
|
||||
@@ -27,10 +26,9 @@ const messages = defineMessages({
|
||||
|
||||
const PersonDetails: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const router = useRouter();
|
||||
const { data, error } = useSWR<PersonDetail>(
|
||||
`/api/v1/person/${router.query.personId}?language=${locale}`
|
||||
`/api/v1/person/${router.query.personId}`
|
||||
);
|
||||
const [showBio, setShowBio] = useState(false);
|
||||
|
||||
@@ -38,7 +36,7 @@ const PersonDetails: React.FC = () => {
|
||||
data: combinedCredits,
|
||||
error: errorCombinedCredits,
|
||||
} = useSWR<PersonCombinedCreditsResponse>(
|
||||
`/api/v1/person/${router.query.personId}/combined_credits?language=${locale}`
|
||||
`/api/v1/person/${router.query.personId}/combined_credits`
|
||||
);
|
||||
|
||||
const sortedCast = useMemo(() => {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { CheckIcon, TrashIcon, XIcon } from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import type { TvDetails } from '../../../server/models/Tv';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import { withProperties } from '../../utils/typeHelpers';
|
||||
@@ -92,13 +91,12 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
|
||||
});
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const url =
|
||||
request.type === 'movie'
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}?language=${locale}` : null
|
||||
inView ? `${url}` : null
|
||||
);
|
||||
const {
|
||||
data: requestData,
|
||||
|
@@ -7,7 +7,7 @@ import {
|
||||
} from '@heroicons/react/solid';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
|
||||
import type { MovieDetails } from '../../../../server/models/Movie';
|
||||
import type { TvDetails } from '../../../../server/models/Tv';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import { Permission, useUser } from '../../../hooks/useUser';
|
||||
import globalMessages from '../../../i18n/globalMessages';
|
||||
import Badge from '../../Common/Badge';
|
||||
@@ -99,13 +98,12 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const url =
|
||||
request.type === 'movie'
|
||||
? `/api/v1/movie/${request.media.tmdbId}`
|
||||
: `/api/v1/tv/${request.media.tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}?language=${locale}` : null
|
||||
inView ? `${url}` : null
|
||||
);
|
||||
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
|
||||
`/api/v1/request/${request.id}`,
|
||||
|
49
src/components/ServiceWorkerSetup/index.tsx
Normal file
49
src/components/ServiceWorkerSetup/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable no-console */
|
||||
import axios from 'axios';
|
||||
import React, { useEffect } from 'react';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
|
||||
const ServiceWorkerSetup: React.FC = () => {
|
||||
const { currentSettings } = useSettings();
|
||||
const { user } = useUser();
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator && user?.id) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then(async (registration) => {
|
||||
console.log(
|
||||
'[SW] Registration successful, scope is:',
|
||||
registration.scope
|
||||
);
|
||||
|
||||
if (currentSettings.enablePushRegistration) {
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: currentSettings.vapidPublic,
|
||||
});
|
||||
|
||||
const parsedSub = JSON.parse(JSON.stringify(sub));
|
||||
|
||||
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
|
||||
await axios.post('/api/v1/user/registerPushSubscription', {
|
||||
endpoint: parsedSub.endpoint,
|
||||
p256dh: parsedSub.keys.p256dh,
|
||||
auth: parsedSub.keys.auth,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.log('[SW] Service worker registration failed, error:', error);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
currentSettings.vapidPublic,
|
||||
currentSettings.enablePushRegistration,
|
||||
]);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ServiceWorkerSetup;
|
@@ -0,0 +1,122 @@
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
import Button from '../../../Common/Button';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import NotificationTypeSelector from '../../../NotificationTypeSelector';
|
||||
|
||||
const messages = defineMessages({
|
||||
agentenabled: 'Enable Agent',
|
||||
webpushsettingssaved: 'Web push notification settings saved successfully!',
|
||||
webpushsettingsfailed: 'Web push notification settings failed to save.',
|
||||
testsent: 'Web push test notification sent!',
|
||||
});
|
||||
|
||||
const NotificationsWebPush: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/webpush'
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/webpush', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.webpushsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.webpushsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, values, isValid, setFieldValue }) => {
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/webpush/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="enabled" className="checkbox-label">
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field type="checkbox" id="enabled" name="enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
|
||||
/>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(globalMessages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWebPush;
|
@@ -1,5 +1,5 @@
|
||||
import { AtSymbolIcon } from '@heroicons/react/outline';
|
||||
import { LightningBoltIcon } from '@heroicons/react/solid';
|
||||
import { CloudIcon, LightningBoltIcon } from '@heroicons/react/solid';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import DiscordLogo from '../../assets/extlogos/discord.svg';
|
||||
@@ -18,6 +18,7 @@ const messages = defineMessages({
|
||||
'Configure and enable notification agents.',
|
||||
email: 'Email',
|
||||
webhook: 'Webhook',
|
||||
webpush: 'Web Push',
|
||||
});
|
||||
|
||||
const SettingsNotifications: React.FC = ({ children }) => {
|
||||
@@ -90,6 +91,17 @@ const SettingsNotifications: React.FC = ({ children }) => {
|
||||
route: '/settings/notifications/telegram',
|
||||
regex: /^\/settings\/notifications\/telegram/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.webpush),
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<CloudIcon className="h-4 mr-2" />
|
||||
{intl.formatMessage(messages.webpush)}
|
||||
</span>
|
||||
),
|
||||
route: '/settings/notifications/webpush',
|
||||
regex: /^\/settings\/notifications\/webpush/,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.webhook),
|
||||
content: (
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import useSWR from 'swr';
|
||||
import TitleCard from '.';
|
||||
import type { MovieDetails } from '../../../server/models/Movie';
|
||||
import type { TvDetails } from '../../../server/models/Tv';
|
||||
import TitleCard from '.';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
|
||||
interface TmdbTitleCardProps {
|
||||
tmdbId: number;
|
||||
@@ -19,11 +18,10 @@ const TmdbTitleCard: React.FC<TmdbTitleCardProps> = ({ tmdbId, type }) => {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
});
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const url =
|
||||
type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`;
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? `${url}?language=${locale}` : null
|
||||
inView ? `${url}` : null
|
||||
);
|
||||
|
||||
if (!title && !error) {
|
||||
|
@@ -7,41 +7,57 @@ import {
|
||||
import { XIcon } from '@heroicons/react/solid';
|
||||
import React from 'react';
|
||||
import type { ToastProps } from 'react-toast-notifications';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const Toast: React.FC<ToastProps> = ({ appearance, children, onDismiss }) => {
|
||||
const Toast: React.FC<ToastProps> = ({
|
||||
appearance,
|
||||
children,
|
||||
onDismiss,
|
||||
transitionState,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-end justify-center px-2 py-2 pointer-events-none toast sm:items-start sm:justify-end">
|
||||
<div className="w-full max-w-sm bg-gray-700 rounded-lg shadow-lg pointer-events-auto">
|
||||
<div className="overflow-hidden rounded-lg ring-1 ring-black ring-opacity-5">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{appearance === 'success' && (
|
||||
<CheckCircleIcon className="w-6 h-6 text-green-400" />
|
||||
)}
|
||||
{appearance === 'error' && (
|
||||
<ExclamationCircleIcon className="w-6 h-6 text-red-500" />
|
||||
)}
|
||||
{appearance === 'info' && (
|
||||
<InformationCircleIcon className="w-6 h-6 text-indigo-500" />
|
||||
)}
|
||||
{appearance === 'warning' && (
|
||||
<ExclamationIcon className="w-6 h-6 text-orange-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 w-0 ml-3 text-white">{children}</div>
|
||||
<div className="flex flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => onDismiss()}
|
||||
className="inline-flex text-gray-400 transition duration-150 ease-in-out focus:outline-none focus:text-gray-500"
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<Transition
|
||||
show={transitionState === 'entered'}
|
||||
enter="transition duration-300 transform-gpu"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition duration-150 transform-gpu"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-90"
|
||||
>
|
||||
<div className="w-full max-w-sm bg-gray-800 rounded-lg shadow-lg pointer-events-auto ring-1 ring-gray-500">
|
||||
<div className="overflow-hidden rounded-lg ring-1 ring-black ring-opacity-5">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{appearance === 'success' && (
|
||||
<CheckCircleIcon className="w-6 h-6 text-green-400" />
|
||||
)}
|
||||
{appearance === 'error' && (
|
||||
<ExclamationCircleIcon className="w-6 h-6 text-red-500" />
|
||||
)}
|
||||
{appearance === 'info' && (
|
||||
<InformationCircleIcon className="w-6 h-6 text-indigo-500" />
|
||||
)}
|
||||
{appearance === 'warning' && (
|
||||
<ExclamationIcon className="w-6 h-6 text-orange-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 w-0 ml-3 text-white">{children}</div>
|
||||
<div className="flex flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={() => onDismiss()}
|
||||
className="inline-flex text-gray-400 transition duration-150 ease-in-out focus:outline-none focus:text-gray-500"
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
22
src/components/ToastContainer/index.tsx
Normal file
22
src/components/ToastContainer/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ToastContainerProps } from 'react-toast-notifications';
|
||||
|
||||
const ToastContainer: React.FC<ToastContainerProps> = ({
|
||||
hasToasts,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
id="toast-container"
|
||||
className="fixed max-w-full max-h-full overflow-hidden top-4 right-4"
|
||||
style={{
|
||||
pointerEvents: hasToasts ? 'all' : 'none',
|
||||
zIndex: 10000,
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
@@ -1,15 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { TvDetails } from '../../../../server/models/Tv';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullseriescast: 'Full Series Cast',
|
||||
@@ -18,10 +17,7 @@ const messages = defineMessages({
|
||||
const TvCast: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<TvDetails>(
|
||||
`/api/v1/tv/${router.query.tvId}?language=${locale}`
|
||||
);
|
||||
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
|
@@ -1,15 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { TvDetails } from '../../../../server/models/Tv';
|
||||
import { LanguageContext } from '../../../context/LanguageContext';
|
||||
import Error from '../../../pages/_error';
|
||||
import Header from '../../Common/Header';
|
||||
import LoadingSpinner from '../../Common/LoadingSpinner';
|
||||
import PersonCard from '../../PersonCard';
|
||||
import PageTitle from '../../Common/PageTitle';
|
||||
import PersonCard from '../../PersonCard';
|
||||
|
||||
const messages = defineMessages({
|
||||
fullseriescrew: 'Full Series Crew',
|
||||
@@ -18,10 +17,7 @@ const messages = defineMessages({
|
||||
const TvCrew: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWR<TvDetails>(
|
||||
`/api/v1/tv/${router.query.tvId}?language=${locale}`
|
||||
);
|
||||
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
|
@@ -1,16 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { useRouter } from 'next/router';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import Header from '../Common/Header';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import Error from '../../pages/_error';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Link from 'next/link';
|
||||
import Error from '../../pages/_error';
|
||||
import Header from '../Common/Header';
|
||||
import ListView from '../Common/ListView';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
recommendations: 'Recommendations',
|
||||
@@ -19,10 +18,7 @@ const messages = defineMessages({
|
||||
const TvRecommendations: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: tvData } = useSWR<TvDetails>(
|
||||
`/api/v1/tv/${router.query.tvId}?language=${locale}`
|
||||
);
|
||||
const { data: tvData } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
|
@@ -1,16 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { useRouter } from 'next/router';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import type { TvDetails } from '../../../server/models/Tv';
|
||||
import Header from '../Common/Header';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
import useDiscover from '../../hooks/useDiscover';
|
||||
import Error from '../../pages/_error';
|
||||
import Link from 'next/link';
|
||||
import Header from '../Common/Header';
|
||||
import ListView from '../Common/ListView';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
similar: 'Similar Series',
|
||||
@@ -19,10 +18,7 @@ const messages = defineMessages({
|
||||
const TvSimilar: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data: tvData } = useSWR<TvDetails>(
|
||||
`/api/v1/tv/${router.query.tvId}?language=${locale}`
|
||||
);
|
||||
const { data: tvData } = useSWR<TvDetails>(`/api/v1/tv/${router.query.tvId}`);
|
||||
const {
|
||||
isLoadingInitialData,
|
||||
isEmpty,
|
||||
|
@@ -12,7 +12,7 @@ import {
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||
@@ -25,7 +25,7 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||
import RTFresh from '../../assets/rt_fresh.svg';
|
||||
import RTRotten from '../../assets/rt_rotten.svg';
|
||||
import TmdbLogo from '../../assets/tmdb_logo.svg';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import useLocale from '../../hooks/useLocale';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
@@ -91,12 +91,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
const { user, hasPermission } = useUser();
|
||||
const router = useRouter();
|
||||
const intl = useIntl();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { locale } = useLocale();
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
|
||||
const { data, error, revalidate } = useSWR<TvDetailsType>(
|
||||
`/api/v1/tv/${router.query.tvId}?language=${locale}`,
|
||||
`/api/v1/tv/${router.query.tvId}`,
|
||||
{
|
||||
initialData: tv,
|
||||
}
|
||||
|
@@ -7,6 +7,8 @@ import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
|
||||
import { Language } from '../../../../../server/lib/settings';
|
||||
import { availableLanguages } from '../../../../context/LanguageContext';
|
||||
import useLocale from '../../../../hooks/useLocale';
|
||||
import useSettings from '../../../../hooks/useSettings';
|
||||
import { Permission, UserType, useUser } from '../../../../hooks/useUser';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
@@ -39,11 +41,13 @@ const messages = defineMessages({
|
||||
movierequestlimit: 'Movie Request Limit',
|
||||
seriesrequestlimit: 'Series Request Limit',
|
||||
enableOverride: 'Enable Override',
|
||||
applanguage: 'Display Language',
|
||||
});
|
||||
|
||||
const UserGeneralSettings: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { locale, setLocale } = useLocale();
|
||||
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
|
||||
const [tvQuotaEnabled, setTvQuotaEnabled] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -115,6 +119,7 @@ const UserGeneralSettings: React.FC = () => {
|
||||
</div>
|
||||
<Formik
|
||||
initialValues={{
|
||||
locale,
|
||||
displayName: data?.username,
|
||||
region: data?.region,
|
||||
originalLanguage: data?.originalLanguage,
|
||||
@@ -136,8 +141,13 @@ const UserGeneralSettings: React.FC = () => {
|
||||
movieQuotaDays: movieQuotaEnabled ? values.movieQuotaDays : null,
|
||||
tvQuotaLimit: tvQuotaEnabled ? values.tvQuotaLimit : null,
|
||||
tvQuotaDays: tvQuotaEnabled ? values.tvQuotaDays : null,
|
||||
locale: values.locale,
|
||||
});
|
||||
|
||||
if (setLocale) {
|
||||
setLocale(values.locale);
|
||||
}
|
||||
|
||||
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
@@ -206,6 +216,24 @@ const UserGeneralSettings: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="locale" className="text-label">
|
||||
{intl.formatMessage(messages.applanguage)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<div className="form-input-field">
|
||||
<Field as="select" id="locale" name="locale">
|
||||
{(Object.keys(
|
||||
availableLanguages
|
||||
) as (keyof typeof availableLanguages)[]).map((key) => (
|
||||
<option key={key} value={availableLanguages[key].code}>
|
||||
{availableLanguages[key].display}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="displayName" className="text-label">
|
||||
<span>{intl.formatMessage(messages.region)}</span>
|
||||
|
@@ -1,20 +1,17 @@
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
|
||||
import {
|
||||
hasNotificationAgentEnabled,
|
||||
NotificationAgentType,
|
||||
} from '../../../../../server/lib/notifications/agenttypes';
|
||||
import { useUser } from '../../../../hooks/useUser';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
import Button from '../../../Common/Button';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
|
||||
|
||||
const messages = defineMessages({
|
||||
discordsettingssaved: 'Discord notification settings saved successfully!',
|
||||
@@ -30,18 +27,11 @@ const UserNotificationsDiscord: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const router = useRouter();
|
||||
const [notificationAgents, setNotificationAgents] = useState(0);
|
||||
const { user } = useUser({ id: Number(router.query.userId) });
|
||||
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
|
||||
user ? `/api/v1/user/${user?.id}/settings/notifications` : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationAgents(
|
||||
data?.notificationAgents ?? NotificationAgentType.EMAIL
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const UserNotificationsDiscordSchema = Yup.object().shape({
|
||||
discordId: Yup.string()
|
||||
.when('enableDiscord', {
|
||||
@@ -61,10 +51,7 @@ const UserNotificationsDiscord: React.FC = () => {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enableDiscord: hasNotificationAgentEnabled(
|
||||
NotificationAgentType.DISCORD,
|
||||
data?.notificationAgents ?? NotificationAgentType.EMAIL
|
||||
),
|
||||
enableDiscord: !!data?.notificationTypes.discord,
|
||||
discordId: data?.discordId,
|
||||
}}
|
||||
validationSchema={UserNotificationsDiscordSchema}
|
||||
@@ -72,11 +59,13 @@ const UserNotificationsDiscord: React.FC = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
|
||||
notificationAgents,
|
||||
pgpKey: data?.pgpKey,
|
||||
discordId: values.discordId,
|
||||
telegramChatId: data?.telegramChatId,
|
||||
telegramSendSilently: data?.telegramSendSilently,
|
||||
notificationTypes: {
|
||||
discord: values.enableDiscord ? ALL_NOTIFICATIONS : 0,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.discordsettingssaved), {
|
||||
appearance: 'success',
|
||||
@@ -92,7 +81,7 @@ const UserNotificationsDiscord: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => {
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
{data?.discordEnabled && (
|
||||
@@ -105,21 +94,6 @@ const UserNotificationsDiscord: React.FC = () => {
|
||||
type="checkbox"
|
||||
id="enableDiscord"
|
||||
name="enableDiscord"
|
||||
checked={hasNotificationAgentEnabled(
|
||||
NotificationAgentType.DISCORD,
|
||||
notificationAgents
|
||||
)}
|
||||
onChange={() => {
|
||||
setNotificationAgents(
|
||||
hasNotificationAgentEnabled(
|
||||
NotificationAgentType.DISCORD,
|
||||
notificationAgents
|
||||
)
|
||||
? notificationAgents - NotificationAgentType.DISCORD
|
||||
: notificationAgents + NotificationAgentType.DISCORD
|
||||
);
|
||||
setFieldValue('enableDiscord', !values.enableDiscord);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,21 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
|
||||
import {
|
||||
hasNotificationAgentEnabled,
|
||||
NotificationAgentType,
|
||||
} from '../../../../../server/lib/notifications/agenttypes';
|
||||
import { useUser } from '../../../../hooks/useUser';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
import Badge from '../../../Common/Badge';
|
||||
import Button from '../../../Common/Button';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
|
||||
import { OpenPgpLink } from '../../../Settings/Notifications/NotificationsEmail';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -32,18 +29,11 @@ const UserEmailSettings: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const router = useRouter();
|
||||
const [notificationAgents, setNotificationAgents] = useState(0);
|
||||
const { user } = useUser({ id: Number(router.query.userId) });
|
||||
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
|
||||
user ? `/api/v1/user/${user?.id}/settings/notifications` : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationAgents(
|
||||
data?.notificationAgents ?? NotificationAgentType.EMAIL
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const UserNotificationsEmailSchema = Yup.object().shape({
|
||||
pgpKey: Yup.string()
|
||||
.nullable()
|
||||
@@ -60,10 +50,7 @@ const UserEmailSettings: React.FC = () => {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enableEmail: hasNotificationAgentEnabled(
|
||||
NotificationAgentType.EMAIL,
|
||||
data?.notificationAgents ?? NotificationAgentType.EMAIL
|
||||
),
|
||||
enableEmail: !!(data?.notificationTypes.email ?? true),
|
||||
pgpKey: data?.pgpKey,
|
||||
}}
|
||||
validationSchema={UserNotificationsEmailSchema}
|
||||
@@ -71,11 +58,13 @@ const UserEmailSettings: React.FC = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
|
||||
notificationAgents,
|
||||
pgpKey: values.pgpKey,
|
||||
discordId: data?.discordId,
|
||||
telegramChatId: data?.telegramChatId,
|
||||
telegramSendSilently: data?.telegramSendSilently,
|
||||
notificationTypes: {
|
||||
email: values.enableEmail ? ALL_NOTIFICATIONS : 0,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.emailsettingssaved), {
|
||||
appearance: 'success',
|
||||
@@ -91,7 +80,7 @@ const UserEmailSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => {
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
@@ -99,26 +88,7 @@ const UserEmailSettings: React.FC = () => {
|
||||
{intl.formatMessage(messages.enableEmail)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enableEmail"
|
||||
name="enableEmail"
|
||||
checked={hasNotificationAgentEnabled(
|
||||
NotificationAgentType.EMAIL,
|
||||
notificationAgents
|
||||
)}
|
||||
onChange={() => {
|
||||
setNotificationAgents(
|
||||
hasNotificationAgentEnabled(
|
||||
NotificationAgentType.EMAIL,
|
||||
notificationAgents
|
||||
)
|
||||
? notificationAgents - NotificationAgentType.EMAIL
|
||||
: notificationAgents + NotificationAgentType.EMAIL
|
||||
);
|
||||
setFieldValue('enableEmail', !values.enableEmail);
|
||||
}}
|
||||
/>
|
||||
<Field type="checkbox" id="enableEmail" name="enableEmail" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
|
@@ -1,20 +1,17 @@
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import * as Yup from 'yup';
|
||||
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
|
||||
import {
|
||||
hasNotificationAgentEnabled,
|
||||
NotificationAgentType,
|
||||
} from '../../../../../server/lib/notifications/agenttypes';
|
||||
import { useUser } from '../../../../hooks/useUser';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
import Button from '../../../Common/Button';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
|
||||
|
||||
const messages = defineMessages({
|
||||
telegramsettingssaved: 'Telegram notification settings saved successfully!',
|
||||
@@ -32,18 +29,11 @@ const UserTelegramSettings: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const router = useRouter();
|
||||
const [notificationAgents, setNotificationAgents] = useState(0);
|
||||
const { user } = useUser({ id: Number(router.query.userId) });
|
||||
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
|
||||
user ? `/api/v1/user/${user?.id}/settings/notifications` : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationAgents(
|
||||
data?.notificationAgents ?? NotificationAgentType.EMAIL
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const UserNotificationsTelegramSchema = Yup.object().shape({
|
||||
telegramChatId: Yup.string()
|
||||
.when('enableTelegram', {
|
||||
@@ -66,10 +56,7 @@ const UserTelegramSettings: React.FC = () => {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enableTelegram: hasNotificationAgentEnabled(
|
||||
NotificationAgentType.TELEGRAM,
|
||||
data?.notificationAgents ?? NotificationAgentType.EMAIL
|
||||
),
|
||||
enableTelegram: !!data?.notificationTypes.telegram,
|
||||
telegramChatId: data?.telegramChatId,
|
||||
telegramSendSilently: data?.telegramSendSilently,
|
||||
}}
|
||||
@@ -78,11 +65,13 @@ const UserTelegramSettings: React.FC = () => {
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
|
||||
notificationAgents,
|
||||
pgpKey: data?.pgpKey,
|
||||
discordId: data?.discordId,
|
||||
telegramChatId: values.telegramChatId,
|
||||
telegramSendSilently: values.telegramSendSilently,
|
||||
notificationTypes: {
|
||||
telegram: values.enableTelegram ? ALL_NOTIFICATIONS : 0,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.telegramsettingssaved), {
|
||||
appearance: 'success',
|
||||
@@ -98,7 +87,7 @@ const UserTelegramSettings: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid, values, setFieldValue }) => {
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
@@ -110,21 +99,6 @@ const UserTelegramSettings: React.FC = () => {
|
||||
type="checkbox"
|
||||
id="enableTelegram"
|
||||
name="enableTelegram"
|
||||
checked={hasNotificationAgentEnabled(
|
||||
NotificationAgentType.TELEGRAM,
|
||||
notificationAgents
|
||||
)}
|
||||
onChange={() => {
|
||||
setNotificationAgents(
|
||||
hasNotificationAgentEnabled(
|
||||
NotificationAgentType.TELEGRAM,
|
||||
notificationAgents
|
||||
)
|
||||
? notificationAgents - NotificationAgentType.TELEGRAM
|
||||
: notificationAgents + NotificationAgentType.TELEGRAM
|
||||
);
|
||||
setFieldValue('enableTelegram', !values.enableTelegram);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,102 @@
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
|
||||
import { useUser } from '../../../../hooks/useUser';
|
||||
import globalMessages from '../../../../i18n/globalMessages';
|
||||
import Button from '../../../Common/Button';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
|
||||
|
||||
const messages = defineMessages({
|
||||
webpushsettingssaved: 'Web push notification settings saved successfully!',
|
||||
webpushsettingsfailed: 'Web push notification settings failed to save.',
|
||||
enableWebPush: 'Enable Notifications',
|
||||
});
|
||||
|
||||
const UserWebPushSettings: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const router = useRouter();
|
||||
const { user } = useUser({ id: Number(router.query.userId) });
|
||||
const { data, error, revalidate } = useSWR<UserSettingsNotificationsResponse>(
|
||||
user ? `/api/v1/user/${user?.id}/settings/notifications` : null
|
||||
);
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enableWebPush: !!(data?.notificationTypes.webpush ?? true),
|
||||
pgpKey: data?.pgpKey,
|
||||
}}
|
||||
enableReinitialize
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
|
||||
discordId: data?.discordId,
|
||||
telegramChatId: data?.telegramChatId,
|
||||
telegramSendSilently: data?.telegramSendSilently,
|
||||
notificationTypes: {
|
||||
webpush: values.enableWebPush ? ALL_NOTIFICATIONS : 0,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.webpushsettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.webpushsettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form className="section">
|
||||
<div className="form-row">
|
||||
<label htmlFor="enableEmail" className="checkbox-label">
|
||||
{intl.formatMessage(messages.enableWebPush)}
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enableWebPush"
|
||||
name="enableWebPush"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(globalMessages.saving)
|
||||
: intl.formatMessage(globalMessages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserWebPushSettings;
|
@@ -1,4 +1,5 @@
|
||||
import { AtSymbolIcon } from '@heroicons/react/outline';
|
||||
import { CloudIcon } from '@heroicons/react/solid';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
@@ -17,6 +18,7 @@ const messages = defineMessages({
|
||||
notifications: 'Notifications',
|
||||
notificationsettings: 'Notification Settings',
|
||||
email: 'Email',
|
||||
webpush: 'Web Push',
|
||||
toastSettingsSuccess: 'Notification settings saved successfully!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
});
|
||||
@@ -65,6 +67,18 @@ const UserNotificationSettings: React.FC = ({ children }) => {
|
||||
regex: /\/settings\/notifications\/telegram/,
|
||||
hidden: !data?.telegramEnabled || !data?.telegramBotUsername,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(messages.webpush),
|
||||
content: (
|
||||
<span className="flex items-center">
|
||||
<CloudIcon className="h-4 mr-2" />
|
||||
{intl.formatMessage(messages.webpush)}
|
||||
</span>
|
||||
),
|
||||
route: '/settings/notifications/webpush',
|
||||
regex: /\/settings\/notifications\/webpush/,
|
||||
hidden: !data?.webPushEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
settingsRoutes.forEach((settingsRoute) => {
|
||||
|
Reference in New Issue
Block a user