mirror of
https://github.com/sct/overseerr.git
synced 2026-01-01 02:26:16 +01:00
This commit is contained in:
93
src/components/Discover/DiscoverTvUpcoming.tsx
Normal file
93
src/components/Discover/DiscoverTvUpcoming.tsx
Normal 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;
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
185
src/components/RegionSelector/index.tsx
Normal file
185
src/components/RegionSelector/index.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user