feat: PWA Support (#1488)

This commit is contained in:
sct
2021-04-25 20:44:12 +09:00
committed by GitHub
parent e6e5ad221a
commit 28830d4ef8
88 changed files with 2022 additions and 650 deletions

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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">

View File

@@ -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));

View File

@@ -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>

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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;

View 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;

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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}`,

View 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;

View File

@@ -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;

View File

@@ -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: (

View File

@@ -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) {

View File

@@ -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>
);
};

View 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;

View File

@@ -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 />;

View File

@@ -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 />;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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;

View File

@@ -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) => {