feat(regions): add region/original language setting for filtering Discover (#732) (#942)

This commit is contained in:
Daniel Carter
2021-02-22 16:39:25 +09:00
committed by GitHub
parent 8701fb20d0
commit b557c06b0a
21 changed files with 787 additions and 33 deletions

View File

@@ -0,0 +1,93 @@
import React, { useContext } from 'react';
import { useSWRInfinite } from 'swr';
import type { TvResult } from '../../../server/models/Search';
import ListView from '../Common/ListView';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
upcomingtv: 'Upcoming Series',
});
interface SearchResult {
page: number;
totalResults: number;
totalPages: number;
results: TvResult[];
}
const DiscoverTvUpcoming: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
return null;
}
return `/api/v1/discover/tv/upcoming?page=${
pageIndex + 1
}&language=${locale}`;
},
{
initialSize: 3,
}
);
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
(size > 0 && data && typeof data[size - 1] === 'undefined');
const fetchMore = () => {
setSize(size + 1);
};
if (error) {
return <div>{error}</div>;
}
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as TvResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);
return (
<>
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
<div className="mt-1 mb-5">
<Header>
<FormattedMessage {...messages.upcomingtv} />
</Header>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isReachingEnd={isReachingEnd}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverTvUpcoming;

View File

@@ -15,6 +15,7 @@ const messages = defineMessages({
recentrequests: 'Recent Requests',
popularmovies: 'Popular Movies',
populartv: 'Popular Series',
upcomingtv: 'Upcoming Series',
recentlyAdded: 'Recently Added',
nopending: 'No Pending Requests',
upcoming: 'Upcoming Movies',
@@ -97,12 +98,6 @@ const Discover: React.FC = () => {
placeholder={<RequestCard.Placeholder />}
emptyMessage={intl.formatMessage(messages.nopending)}
/>
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<MediaSlider
sliderKey="trending"
title={intl.formatMessage(messages.trending)}
@@ -115,12 +110,24 @@ const Discover: React.FC = () => {
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
linkUrl="/discover/movies/upcoming"
url="/api/v1/discover/movies/upcoming"
/>
<MediaSlider
sliderKey="popular-tv"
title={intl.formatMessage(messages.populartv)}
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}
url="/api/v1/discover/tv/upcoming"
linkUrl="/discover/tv/upcoming"
/>
</>
);
};

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { countryCodeEmoji } from 'country-code-emoji';
import useSWR from 'swr';
import type { Region } from '../../../server/lib/settings';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
regionDefault: 'All',
});
interface RegionSelectorProps {
value: string;
name: string;
onChange?: (fieldName: string, region: string) => void;
}
const RegionSelector: React.FC<RegionSelectorProps> = ({
name,
value,
onChange,
}) => {
const intl = useIntl();
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
useEffect(() => {
if (regions && value) {
const matchedRegion = regions.find(
(region) => region.iso_3166_1 === value
);
setSelectedRegion(matchedRegion ?? null);
}
}, [value, regions]);
useEffect(() => {
if (onChange && regions) {
onChange(name, selectedRegion?.iso_3166_1 ?? '');
}
}, [onChange, selectedRegion, name, regions]);
return (
<div className="relative z-40 flex max-w-lg">
<div className="w-full">
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
{({ open }) => (
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
{selectedRegion && (
<span className="h-4 mr-2 overflow-hidden text-lg leading-4">
{countryCodeEmoji(selectedRegion.iso_3166_1)}
</span>
)}
<span className="block truncate">
{selectedRegion
? intl.formatDisplayName(selectedRegion.iso_3166_1, {
type: 'region',
})
: intl.formatMessage(messages.regionDefault)}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 20"
className="w-5 h-5 text-gray-500"
>
<path
stroke="#6b7280"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M6 8l4 4 4-4"
/>
</svg>
</span>
</Listbox.Button>
</span>
<Transition
show={open}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
className="absolute w-full mt-1 bg-gray-800 rounded-md shadow-lg"
>
<Listbox.Options
static
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<Listbox.Option value={null}>
{({ selected, active }) => (
<div
className={`${
active ? 'text-white bg-indigo-600' : 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4`}
>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{intl.formatMessage(messages.regionDefault)}
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
{regions?.map((region) => (
<Listbox.Option key={region.iso_3166_1} value={region}>
{({ selected, active }) => (
<div
className={`${
active
? 'text-white bg-indigo-600'
: 'text-gray-300'
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
>
<span className="mr-2 text-lg">
{countryCodeEmoji(region.iso_3166_1)}
</span>
<span
className={`${
selected ? 'font-semibold' : 'font-normal'
} block truncate`}
>
{intl.formatDisplayName(region.iso_3166_1, {
type: 'region',
})}
</span>
{selected && (
<span
className={`${
active ? 'text-white' : 'text-indigo-600'
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
</div>
</div>
);
};
export default RegionSelector;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import useSWR from 'swr';
import LoadingSpinner from '../Common/LoadingSpinner';
import type { MainSettings } from '../../../server/lib/settings';
import type { MainSettings, Language } from '../../../server/lib/settings';
import CopyButton from './CopyButton';
import { Form, Formik, Field } from 'formik';
import axios from 'axios';
@@ -13,6 +13,7 @@ import Badge from '../Common/Badge';
import globalMessages from '../../i18n/globalMessages';
import PermissionEdit from '../PermissionEdit';
import * as Yup from 'yup';
import RegionSelector from '../RegionSelector';
const messages = defineMessages({
generalsettings: 'General Settings',
@@ -23,6 +24,12 @@ const messages = defineMessages({
apikey: 'API Key',
applicationTitle: 'Application Title',
applicationurl: 'Application URL',
region: 'Discover Region',
regionTip:
'Filter content by region (only applies to the "Popular" and "Upcoming" categories)',
originallanguage: 'Discover Language',
originallanguageTip:
'Filter content by original language (only applies to the "Popular" and "Upcoming" categories)',
toastApiKeySuccess: 'New API key generated!',
toastApiKeyFailure: 'Something went wrong while generating a new API key.',
toastSettingsSuccess: 'Settings successfully saved!',
@@ -50,6 +57,9 @@ const SettingsMain: React.FC = () => {
const { data, error, revalidate } = useSWR<MainSettings>(
'/api/v1/settings/main'
);
const { data: languages, error: languagesError } = useSWR<Language[]>(
'/api/v1/languages'
);
const MainSettingsSchema = Yup.object().shape({
applicationTitle: Yup.string().required(
intl.formatMessage(messages.validationApplicationTitle)
@@ -85,7 +95,7 @@ const SettingsMain: React.FC = () => {
}
};
if (!data && !error) {
if (!data && !error && !languages && !languagesError) {
return <LoadingSpinner />;
}
@@ -108,6 +118,8 @@ const SettingsMain: React.FC = () => {
defaultPermissions: data?.defaultPermissions ?? 0,
hideAvailable: data?.hideAvailable,
localLogin: data?.localLogin,
region: data?.region,
originalLanguage: data?.originalLanguage,
trustProxy: data?.trustProxy,
}}
enableReinitialize
@@ -121,6 +133,8 @@ const SettingsMain: React.FC = () => {
defaultPermissions: values.defaultPermissions,
hideAvailable: values.hideAvailable,
localLogin: values.localLogin,
region: values.region,
originalLanguage: values.originalLanguage,
trustProxy: values.trustProxy,
});
@@ -263,6 +277,51 @@ const SettingsMain: React.FC = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="region" className="text-label">
<span>{intl.formatMessage(messages.region)}</span>
<span className="label-tip">
{intl.formatMessage(messages.regionTip)}
</span>
</label>
<div className="form-input">
<RegionSelector
value={values.region ?? ''}
name="region"
onChange={setFieldValue}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="originalLanguage" className="text-label">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="label-tip">
{intl.formatMessage(messages.originallanguageTip)}
</span>
</label>
<div className="form-input">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
as="select"
id="originalLanguage"
name="originalLanguage"
>
<option value="">All</option>
{languages?.map((language) => (
<option
key={`language-key-${language.iso_639_1}`}
value={language.iso_639_1}
>
{intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name}
</option>
))}
</Field>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="hideAvailable" className="checkbox-label">
<span className="mr-2">

View File

@@ -5,11 +5,13 @@ import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { Language } from '../../../../../server/lib/settings';
import { UserType, useUser } from '../../../../hooks/useUser';
import Error from '../../../../pages/_error';
import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import RegionSelector from '../../../RegionSelector';
const messages = defineMessages({
generalsettings: 'General Settings',
@@ -20,6 +22,12 @@ const messages = defineMessages({
localuser: 'Local User',
toastSettingsSuccess: 'Settings successfully saved!',
toastSettingsFailure: 'Something went wrong while saving settings.',
region: 'Discover Region',
regionTip:
'Filter content by region (only applies to the "Popular" and "Upcoming" categories)',
originallanguage: 'Discover Language',
originallanguageTip:
'Filter content by original language (only applies to the "Popular" and "Upcoming" categories)',
});
const UserGeneralSettings: React.FC = () => {
@@ -27,15 +35,25 @@ const UserGeneralSettings: React.FC = () => {
const { addToast } = useToasts();
const router = useRouter();
const { user, mutate } = useUser({ id: Number(router.query.userId) });
const { data, error, revalidate } = useSWR<{ username?: string }>(
user ? `/api/v1/user/${user?.id}/settings/main` : null
const { data, error, revalidate } = useSWR<{
username?: string;
region?: string;
originalLanguage?: string;
}>(user ? `/api/v1/user/${user?.id}/settings/main` : null);
const { data: languages, error: languagesError } = useSWR<Language[]>(
'/api/v1/languages'
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
if (!languages && !languagesError) {
return <LoadingSpinner />;
}
if (!data || !languages) {
return <Error statusCode={500} />;
}
@@ -49,12 +67,16 @@ const UserGeneralSettings: React.FC = () => {
<Formik
initialValues={{
displayName: data?.username,
region: data?.region,
originalLanguage: data?.originalLanguage,
}}
enableReinitialize
onSubmit={async (values) => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/main`, {
username: values.displayName,
region: values.region,
originalLanguage: values.originalLanguage,
});
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
@@ -72,7 +94,7 @@ const UserGeneralSettings: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting }) => {
{({ errors, touched, isSubmitting, values, setFieldValue }) => {
return (
<Form className="section">
<div className="form-row">
@@ -109,6 +131,51 @@ const UserGeneralSettings: React.FC = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="displayName" className="text-label">
<span>{intl.formatMessage(messages.region)}</span>
<span className="label-tip">
{intl.formatMessage(messages.regionTip)}
</span>
</label>
<div className="form-input">
<RegionSelector
name="region"
value={values.region ?? ''}
onChange={setFieldValue}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="originalLanguage" className="text-label">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="label-tip">
{intl.formatMessage(messages.originallanguageTip)}
</span>
</label>
<div className="form-input">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
as="select"
id="originalLanguage"
name="originalLanguage"
>
<option value="">All</option>
{languages?.map((language) => (
<option
key={`language-key-${language.iso_639_1}`}
value={language.iso_639_1}
>
{intl.formatDisplayName(language.iso_639_1, {
type: 'language',
fallback: 'none',
}) ?? language.english_name}
</option>
))}
</Field>
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">