mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05:00
Merge upstream/develop
This commit is contained in:
@@ -349,7 +349,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
<div className="pb-8" />
|
||||
<div className="extra-bottom-space relative" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ const Badge = (
|
||||
'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100'
|
||||
);
|
||||
if (href) {
|
||||
badgeStyle.push('hover:bg-indigo-500 bg-opacity-100');
|
||||
badgeStyle.push('hover:bg-indigo-500 hover:bg-opacity-100');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type {
|
||||
CollectionResult,
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
@@ -12,7 +13,7 @@ import type {
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
type ListViewProps = {
|
||||
items?: (TvResult | MovieResult | PersonResult)[];
|
||||
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
|
||||
plexItems?: WatchlistItem[];
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -94,6 +95,18 @@ const ListView = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'collection':
|
||||
titleCard = (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
mediaType={title.mediaType}
|
||||
canExpand
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'person':
|
||||
titleCard = (
|
||||
<PersonCard
|
||||
|
||||
@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { sliderTitles } from '@app/components/Discover/constants';
|
||||
import MediaSlider from '@app/components/MediaSlider';
|
||||
import { WatchProviderSelector } from '@app/components/Selector';
|
||||
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import type {
|
||||
TmdbCompanySearchResponse,
|
||||
@@ -55,7 +56,7 @@ type CreateOption = {
|
||||
dataUrl: string;
|
||||
params?: string;
|
||||
titlePlaceholderText: string;
|
||||
dataPlaceholderText: string;
|
||||
dataPlaceholderText?: string;
|
||||
};
|
||||
|
||||
const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
@@ -276,6 +277,20 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
dataPlaceholderText: intl.formatMessage(messages.providetmdbsearch),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES,
|
||||
title: intl.formatMessage(sliderTitles.tmdbmoviestreamingservices),
|
||||
dataUrl: '/api/v1/discover/movies',
|
||||
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
},
|
||||
{
|
||||
type: DiscoverSliderType.TMDB_TV_STREAMING_SERVICES,
|
||||
title: intl.formatMessage(sliderTitles.tmdbtvstreamingservices),
|
||||
dataUrl: '/api/v1/discover/tv',
|
||||
params: 'watchRegion=$regionValue&watchProviders=$providersValue',
|
||||
titlePlaceholderText: intl.formatMessage(messages.slidernameplaceholder),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -417,6 +432,40 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
dataInput = (
|
||||
<WatchProviderSelector
|
||||
type={'movie'}
|
||||
region={slider?.data?.split(',')[0]}
|
||||
activeProviders={
|
||||
slider?.data
|
||||
?.split(',')[1]
|
||||
.split('|')
|
||||
.map((v) => Number(v)) ?? []
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
setFieldValue('data', `${region},${providers.join('|')}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
dataInput = (
|
||||
<WatchProviderSelector
|
||||
type={'tv'}
|
||||
region={slider?.data?.split(',')[0]}
|
||||
activeProviders={
|
||||
slider?.data
|
||||
?.split(',')[1]
|
||||
.split('|')
|
||||
.map((v) => Number(v)) ?? []
|
||||
}
|
||||
onChange={(region, providers) => {
|
||||
setFieldValue('data', `${region},${providers.join('|')}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
dataInput = (
|
||||
<Field
|
||||
@@ -488,10 +537,25 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)}
|
||||
extraParams={
|
||||
activeOption.type ===
|
||||
DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES ||
|
||||
activeOption.type ===
|
||||
DiscoverSliderType.TMDB_TV_STREAMING_SERVICES
|
||||
? activeOption.params
|
||||
?.replace(
|
||||
'$regionValue',
|
||||
encodeURIExtraParams(values?.data.split(',')[0])
|
||||
)
|
||||
.replace(
|
||||
'$providersValue',
|
||||
encodeURIExtraParams(values?.data.split(',')[1])
|
||||
)
|
||||
: activeOption.params?.replace(
|
||||
'$value',
|
||||
encodeURIExtraParams(values.data)
|
||||
)
|
||||
}
|
||||
onNewTitles={updateResultCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,10 @@ const DiscoverSliderEdit = ({
|
||||
return intl.formatMessage(sliderTitles.tmdbnetwork);
|
||||
case DiscoverSliderType.TMDB_SEARCH:
|
||||
return intl.formatMessage(sliderTitles.tmdbsearch);
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices);
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
return intl.formatMessage(sliderTitles.tmdbtvstreamingservices);
|
||||
default:
|
||||
return 'Unknown Slider';
|
||||
}
|
||||
@@ -195,7 +199,9 @@ const DiscoverSliderEdit = ({
|
||||
className={`${slider.data ? 'mb-4' : 'mb-0'} flex space-x-2 md:mb-0`}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
<div>{getSliderTitle(slider)}</div>
|
||||
<div className="w-7/12 truncate md:w-full">
|
||||
{getSliderTitle(slider)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`pointer-events-none ${
|
||||
|
||||
@@ -35,8 +35,10 @@ const messages = defineMessages({
|
||||
ratingText: 'Ratings between {minValue} and {maxValue}',
|
||||
clearfilters: 'Clear Active Filters',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
tmdbuservotecount: 'TMDB User Vote Count',
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -246,6 +248,45 @@ const FilterSlideover = ({
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.tmdbuservotecount)}
|
||||
</span>
|
||||
<div className="relative z-0">
|
||||
<MultiRangeSlider
|
||||
min={0}
|
||||
max={1000}
|
||||
defaultMaxValue={
|
||||
currentFilters.voteCountLte
|
||||
? Number(currentFilters.voteCountLte)
|
||||
: undefined
|
||||
}
|
||||
defaultMinValue={
|
||||
currentFilters.voteCountGte
|
||||
? Number(currentFilters.voteCountGte)
|
||||
: undefined
|
||||
}
|
||||
onUpdateMin={(min) => {
|
||||
updateQueryParams(
|
||||
'voteCountGte',
|
||||
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
|
||||
? min.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
onUpdateMax={(max) => {
|
||||
updateQueryParams(
|
||||
'voteCountLte',
|
||||
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
|
||||
? max.toString()
|
||||
: undefined
|
||||
);
|
||||
}}
|
||||
subText={intl.formatMessage(messages.voteCount, {
|
||||
minValue: currentFilters.voteCountGte ?? 0,
|
||||
maxValue: currentFilters.voteCountLte ?? 1000,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.streamingservices)}
|
||||
</span>
|
||||
|
||||
@@ -86,6 +86,8 @@ export const sliderTitles = defineMessages({
|
||||
tmdbnetwork: 'TMDB Network',
|
||||
tmdbstudio: 'TMDB Studio',
|
||||
tmdbsearch: 'TMDB Search',
|
||||
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
|
||||
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
|
||||
});
|
||||
|
||||
export const QueryFilterOptions = z.object({
|
||||
@@ -102,6 +104,8 @@ export const QueryFilterOptions = z.object({
|
||||
withRuntimeLte: z.string().optional(),
|
||||
voteAverageGte: z.string().optional(),
|
||||
voteAverageLte: z.string().optional(),
|
||||
voteCountLte: z.string().optional(),
|
||||
voteCountGte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
});
|
||||
@@ -167,6 +171,14 @@ export const prepareFilterValues = (
|
||||
filterValues.voteAverageLte = values.voteAverageLte;
|
||||
}
|
||||
|
||||
if (values.voteCountGte) {
|
||||
filterValues.voteCountGte = values.voteCountGte;
|
||||
}
|
||||
|
||||
if (values.voteCountLte) {
|
||||
filterValues.voteCountLte = values.voteCountLte;
|
||||
}
|
||||
|
||||
if (values.watchProviders) {
|
||||
filterValues.watchProviders = values.watchProviders;
|
||||
}
|
||||
@@ -188,6 +200,12 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
delete clonedFilters.voteAverageLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.voteCountGte || filterValues.voteCountLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.voteCountGte;
|
||||
delete clonedFilters.voteCountLte;
|
||||
}
|
||||
|
||||
if (clonedFilters.withRuntimeGte || filterValues.withRuntimeLte) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.withRuntimeGte;
|
||||
|
||||
@@ -365,6 +365,36 @@ const Discover = () => {
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_MOVIE_STREAMING_SERVICES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/movies"
|
||||
extraParams={`watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
linkUrl={`/discover/movies?watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES:
|
||||
sliderComponent = (
|
||||
<MediaSlider
|
||||
sliderKey={`custom-slider-${slider.id}`}
|
||||
title={slider.title ?? ''}
|
||||
url="/api/v1/discover/tv"
|
||||
extraParams={`watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
linkUrl={`/discover/tv?watchRegion=${
|
||||
slider.data?.split(',')[0]
|
||||
}&watchProviders=${slider.data?.split(',')[1]}`}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
|
||||
118
src/components/Layout/PullToRefresh/index.tsx
Normal file
118
src/components/Layout/PullToRefresh/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const PullToRefresh = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [pullStartPoint, setPullStartPoint] = useState(0);
|
||||
const [pullChange, setPullChange] = useState(0);
|
||||
const refreshDiv = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Various pull down thresholds that determine icon location
|
||||
const pullDownInitThreshold = pullChange > 20;
|
||||
const pullDownStopThreshold = 120;
|
||||
const pullDownReloadThreshold = pullChange > 340;
|
||||
const pullDownIconLocation = pullChange / 3;
|
||||
|
||||
useEffect(() => {
|
||||
// Reload function that is called when reload threshold has been hit
|
||||
// Add loading class to determine when to add spin animation
|
||||
const forceReload = () => {
|
||||
refreshDiv.current?.classList.add('loading');
|
||||
setTimeout(() => {
|
||||
router.reload();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const html = document.querySelector('html');
|
||||
|
||||
// Determines if we are at the top of the page
|
||||
// Locks or unlocks page when pulling down to refresh
|
||||
const pullStart = (e: TouchEvent) => {
|
||||
setPullStartPoint(e.targetTouches[0].screenY);
|
||||
|
||||
if (window.scrollY === 0 && window.scrollX === 0) {
|
||||
refreshDiv.current?.classList.add('block');
|
||||
refreshDiv.current?.classList.remove('hidden');
|
||||
document.body.style.touchAction = 'none';
|
||||
document.body.style.overscrollBehavior = 'none';
|
||||
if (html) {
|
||||
html.style.overscrollBehaviorY = 'none';
|
||||
}
|
||||
} else {
|
||||
refreshDiv.current?.classList.remove('block');
|
||||
refreshDiv.current?.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks how far we have pulled down the refresh icon
|
||||
const pullDown = async (e: TouchEvent) => {
|
||||
const screenY = e.targetTouches[0].screenY;
|
||||
|
||||
const pullLength =
|
||||
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
|
||||
|
||||
setPullChange(pullLength);
|
||||
};
|
||||
|
||||
// Will reload the page if we are past the threshold
|
||||
// Otherwise, we reset the pull
|
||||
const pullFinish = () => {
|
||||
setPullStartPoint(0);
|
||||
|
||||
if (pullDownReloadThreshold) {
|
||||
forceReload();
|
||||
} else {
|
||||
setPullChange(0);
|
||||
}
|
||||
|
||||
document.body.style.touchAction = 'auto';
|
||||
document.body.style.overscrollBehaviorY = 'auto';
|
||||
if (html) {
|
||||
html.style.overscrollBehaviorY = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('touchstart', pullStart, { passive: false });
|
||||
window.addEventListener('touchmove', pullDown, { passive: false });
|
||||
window.addEventListener('touchend', pullFinish, { passive: false });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', pullStart);
|
||||
window.removeEventListener('touchmove', pullDown);
|
||||
window.removeEventListener('touchend', pullFinish);
|
||||
};
|
||||
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refreshDiv}
|
||||
className="absolute left-0 right-0 top-0 z-50 m-auto w-fit transition-all ease-out"
|
||||
id="refreshIcon"
|
||||
style={{
|
||||
top:
|
||||
pullDownIconLocation < pullDownStopThreshold && pullDownInitThreshold
|
||||
? pullDownIconLocation
|
||||
: pullDownInitThreshold
|
||||
? pullDownStopThreshold
|
||||
: '',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
|
||||
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
>
|
||||
<ArrowPathIcon
|
||||
className={`rounded-full ${
|
||||
pullDownReloadThreshold && 'rotate-180'
|
||||
} text-indigo-500 transition-all duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullToRefresh;
|
||||
@@ -1,8 +1,8 @@
|
||||
import MobileMenu from '@app/components/Layout/MobileMenu';
|
||||
import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
||||
import SearchInput from '@app/components/Layout/SearchInput';
|
||||
import Sidebar from '@app/components/Layout/Sidebar';
|
||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||
import PullToRefresh from '@app/components/PullToRefresh';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { useRouter } from 'next/router';
|
||||
import PR from 'pulltorefreshjs';
|
||||
import { useEffect } from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
|
||||
const PullToRefresh = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
PR.init({
|
||||
mainElement: '#pull-to-refresh',
|
||||
onRefresh() {
|
||||
router.reload();
|
||||
},
|
||||
iconArrow: ReactDOMServer.renderToString(
|
||||
<div className="p-2">
|
||||
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
||||
</div>
|
||||
),
|
||||
iconRefreshing: ReactDOMServer.renderToString(
|
||||
<div
|
||||
className="animate-spin p-2"
|
||||
style={{ animationDirection: 'reverse' }}
|
||||
>
|
||||
<ArrowPathIcon className="z-50 m-auto h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 text-indigo-500 ring-1 ring-gray-700" />
|
||||
</div>
|
||||
),
|
||||
instructionsPullToRefresh: ReactDOMServer.renderToString(<div />),
|
||||
instructionsReleaseToRefresh: ReactDOMServer.renderToString(<div />),
|
||||
instructionsRefreshing: ReactDOMServer.renderToString(<div />),
|
||||
distReload: 60,
|
||||
distIgnore: 15,
|
||||
shouldPullToRefresh: () =>
|
||||
!window.scrollY && document.body.style.overflow !== 'hidden',
|
||||
});
|
||||
return () => {
|
||||
PR.destroyAll();
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return <div id="pull-to-refresh"></div>;
|
||||
};
|
||||
|
||||
export default PullToRefresh;
|
||||
@@ -169,15 +169,19 @@ export const GenreSelector = ({
|
||||
loadDefaultGenre();
|
||||
}, [defaultValue, type]);
|
||||
|
||||
const loadGenreOptions = async () => {
|
||||
const loadGenreOptions = async (inputValue: string) => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
`/api/v1/discover/genreslider/${type}`
|
||||
);
|
||||
|
||||
return results.data.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
return results.data
|
||||
.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}))
|
||||
.filter(({ label }) =>
|
||||
label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -305,7 +309,9 @@ export const WatchProviderSelector = ({
|
||||
|
||||
useEffect(() => {
|
||||
onChange(watchRegion, activeProvider);
|
||||
}, [activeProvider, watchRegion, onChange]);
|
||||
// removed onChange as a dependency as we only need to call it when the value(s) change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeProvider, watchRegion]);
|
||||
|
||||
const orderedData = useMemo(() => {
|
||||
if (!data) {
|
||||
@@ -344,7 +350,7 @@ export const WatchProviderSelector = ({
|
||||
<SmallLoadingSpinner />
|
||||
) : (
|
||||
<div className="grid">
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="provider-icons grid gap-2">
|
||||
{initialProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
@@ -353,7 +359,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
||||
isActive
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
@@ -386,7 +392,7 @@ export const WatchProviderSelector = ({
|
||||
})}
|
||||
</div>
|
||||
{showMore && otherProviders.length > 0 && (
|
||||
<div className="relative top-2 grid grid-cols-6 gap-2">
|
||||
<div className="provider-icons relative top-2 grid gap-2">
|
||||
{otherProviders.map((provider) => {
|
||||
const isActive = activeProvider.includes(provider.id);
|
||||
return (
|
||||
@@ -395,7 +401,7 @@ export const WatchProviderSelector = ({
|
||||
key={`prodiver-${provider.id}`}
|
||||
>
|
||||
<div
|
||||
className={`provider-container relative h-full w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
||||
isActive
|
||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||
|
||||
@@ -57,6 +57,9 @@ const messages = defineMessages({
|
||||
testFirstTags: 'Test connection to load tags',
|
||||
tags: 'Tags',
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
tagRequests: 'Tag Requests',
|
||||
tagRequestsInfo:
|
||||
"Automatically add an additional tag with the requester's user ID & display name",
|
||||
validationApplicationUrl: 'You must provide a valid URL',
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'URL base must have a leading slash',
|
||||
@@ -238,6 +241,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
externalUrl: radarr?.externalUrl,
|
||||
syncEnabled: radarr?.syncEnabled ?? false,
|
||||
enableSearch: !radarr?.preventSearch,
|
||||
tagRequests: radarr?.tagRequests ?? false,
|
||||
}}
|
||||
validationSchema={RadarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
@@ -263,6 +267,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: !values.enableSearch,
|
||||
tagRequests: values.tagRequests,
|
||||
};
|
||||
if (!radarr) {
|
||||
await axios.post('/api/v1/settings/radarr', submission);
|
||||
@@ -713,6 +718,21 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tagRequests" className="checkbox-label">
|
||||
{intl.formatMessage(messages.tagRequests)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="tagRequests"
|
||||
name="tagRequests"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -62,6 +62,9 @@ const messages = defineMessages({
|
||||
syncEnabled: 'Enable Scan',
|
||||
externalUrl: 'External URL',
|
||||
enableSearch: 'Enable Automatic Search',
|
||||
tagRequests: 'Tag Requests',
|
||||
tagRequestsInfo:
|
||||
"Automatically add an additional tag with the requester's user ID & display name",
|
||||
validationApplicationUrl: 'You must provide a valid URL',
|
||||
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
|
||||
@@ -252,6 +255,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
externalUrl: sonarr?.externalUrl,
|
||||
syncEnabled: sonarr?.syncEnabled ?? false,
|
||||
enableSearch: !sonarr?.preventSearch,
|
||||
tagRequests: sonarr?.tagRequests ?? false,
|
||||
}}
|
||||
validationSchema={SonarrSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
@@ -292,6 +296,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
externalUrl: values.externalUrl,
|
||||
syncEnabled: values.syncEnabled,
|
||||
preventSearch: !values.enableSearch,
|
||||
tagRequests: values.tagRequests,
|
||||
};
|
||||
if (!sonarr) {
|
||||
await axios.post('/api/v1/settings/sonarr', submission);
|
||||
@@ -960,6 +965,21 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="tagRequests" className="checkbox-label">
|
||||
{intl.formatMessage(messages.tagRequests)}
|
||||
<span className="label-tip">
|
||||
{intl.formatMessage(messages.tagRequestsInfo)}
|
||||
</span>
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="tagRequests"
|
||||
name="tagRequests"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ interface TitleCardProps {
|
||||
summary?: string;
|
||||
year?: string;
|
||||
title: string;
|
||||
userScore: number;
|
||||
userScore?: number;
|
||||
mediaType: MediaType;
|
||||
status?: MediaStatus;
|
||||
canExpand?: boolean;
|
||||
@@ -157,7 +157,9 @@ const TitleCard = ({
|
||||
const showRequestButton = hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
mediaType === 'movie' ? Permission.REQUEST_MOVIE : Permission.REQUEST_TV,
|
||||
mediaType === 'movie' || mediaType === 'collection'
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
{ type: 'or' }
|
||||
);
|
||||
@@ -170,7 +172,13 @@ const TitleCard = ({
|
||||
<RequestModal
|
||||
tmdbId={id}
|
||||
show={showRequestModal}
|
||||
type={mediaType === 'movie' ? 'movie' : 'tv'}
|
||||
type={
|
||||
mediaType === 'movie'
|
||||
? 'movie'
|
||||
: mediaType === 'collection'
|
||||
? 'collection'
|
||||
: 'tv'
|
||||
}
|
||||
onComplete={requestComplete}
|
||||
onUpdating={requestUpdating}
|
||||
onCancel={closeModal}
|
||||
@@ -214,7 +222,7 @@ const TitleCard = ({
|
||||
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
|
||||
<div
|
||||
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
|
||||
mediaType === 'movie'
|
||||
mediaType === 'movie' || mediaType === 'collection'
|
||||
? 'border-blue-500 bg-blue-600'
|
||||
: 'border-purple-600 bg-purple-600'
|
||||
}`}
|
||||
@@ -222,6 +230,8 @@ const TitleCard = ({
|
||||
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
|
||||
{mediaType === 'movie'
|
||||
? intl.formatMessage(globalMessages.movie)
|
||||
: mediaType === 'collection'
|
||||
? intl.formatMessage(globalMessages.collection)
|
||||
: intl.formatMessage(globalMessages.tvshow)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +293,15 @@ const TitleCard = ({
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden rounded-xl">
|
||||
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
||||
<Link
|
||||
href={
|
||||
mediaType === 'movie'
|
||||
? `/movie/${id}`
|
||||
: mediaType === 'collection'
|
||||
? `/collection/${id}`
|
||||
: `/tv/${id}`
|
||||
}
|
||||
>
|
||||
<a
|
||||
className="absolute inset-0 h-full w-full cursor-pointer overflow-hidden text-left"
|
||||
style={{
|
||||
|
||||
@@ -15,13 +15,20 @@ export const useLockBodyScroll = (
|
||||
disabled?: boolean
|
||||
): void => {
|
||||
useEffect(() => {
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
const originalOverflowStyle = window.getComputedStyle(
|
||||
document.body
|
||||
).overflow;
|
||||
const originalTouchActionStyle = window.getComputedStyle(
|
||||
document.body
|
||||
).touchAction;
|
||||
if (isLocked && !disabled) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.touchAction = 'none';
|
||||
}
|
||||
return () => {
|
||||
if (!disabled) {
|
||||
document.body.style.overflow = originalStyle;
|
||||
document.body.style.overflow = originalOverflowStyle;
|
||||
document.body.style.touchAction = originalTouchActionStyle;
|
||||
}
|
||||
};
|
||||
}, [isLocked, disabled]);
|
||||
|
||||
@@ -16,6 +16,7 @@ const globalMessages = defineMessages({
|
||||
approved: 'Approved',
|
||||
movie: 'Movie',
|
||||
movies: 'Movies',
|
||||
collection: 'Collection',
|
||||
tvshow: 'Series',
|
||||
tvshows: 'Series',
|
||||
cancel: 'Cancel',
|
||||
|
||||
@@ -14,6 +14,24 @@
|
||||
"components.Discover.DiscoverTvLanguage.languageSeries": "{language} Series",
|
||||
"components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist",
|
||||
"components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist",
|
||||
"components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}",
|
||||
"components.Discover.FilterSlideover.clearfilters": "Clear Active Filters",
|
||||
"components.Discover.FilterSlideover.filters": "Filters",
|
||||
"components.Discover.FilterSlideover.firstAirDate": "First Air Date",
|
||||
"components.Discover.FilterSlideover.from": "From",
|
||||
"components.Discover.FilterSlideover.genres": "Genres",
|
||||
"components.Discover.FilterSlideover.keywords": "Keywords",
|
||||
"components.Discover.FilterSlideover.originalLanguage": "Original Language",
|
||||
"components.Discover.FilterSlideover.ratingText": "Ratings between {minValue} and {maxValue}",
|
||||
"components.Discover.FilterSlideover.releaseDate": "Release Date",
|
||||
"components.Discover.FilterSlideover.runtime": "Runtime",
|
||||
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime",
|
||||
"components.Discover.FilterSlideover.streamingservices": "Streaming Services",
|
||||
"components.Discover.FilterSlideover.studio": "Studio",
|
||||
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
|
||||
"components.Discover.FilterSlideover.tmdbuservotecount": "TMDB User Vote Count",
|
||||
"components.Discover.FilterSlideover.to": "To",
|
||||
"components.Discover.FilterSlideover.voteCount": "Number of votes between {minValue} and {maxValue}",
|
||||
"components.Discover.MovieGenreList.moviegenres": "Movie Genres",
|
||||
"components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",
|
||||
"components.Discover.NetworkSlider.networks": "Networks",
|
||||
@@ -30,6 +48,21 @@
|
||||
"components.Discover.populartv": "Popular Series",
|
||||
"components.Discover.recentlyAdded": "Recently Added",
|
||||
"components.Discover.recentrequests": "Recent Requests",
|
||||
"components.Discover.resetfailed": "Something went wrong resetting the discover customization settings.",
|
||||
"components.Discover.resetsuccess": "Sucessfully reset discover customization settings.",
|
||||
"components.Discover.resettodefault": "Reset to Default",
|
||||
"components.Discover.resetwarning": "Reset all sliders to default. This will also delete any custom sliders!",
|
||||
"components.Discover.stopediting": "Stop Editing",
|
||||
"components.Discover.studios": "Studios",
|
||||
"components.Discover.tmdbmoviegenre": "TMDB Movie Genre",
|
||||
"components.Discover.tmdbmoviekeyword": "TMDB Movie Keyword",
|
||||
"components.Discover.tmdbmoviestreamingservices": "TMDB Movie Streaming Services",
|
||||
"components.Discover.tmdbnetwork": "TMDB Network",
|
||||
"components.Discover.tmdbsearch": "TMDB Search",
|
||||
"components.Discover.tmdbstudio": "TMDB Studio",
|
||||
"components.Discover.tmdbtvgenre": "TMDB Series Genre",
|
||||
"components.Discover.tmdbtvkeyword": "TMDB Series Keyword",
|
||||
"components.Discover.tmdbtvstreamingservices": "TMDB TV Streaming Services",
|
||||
"components.Discover.trending": "Trending",
|
||||
"components.Discover.upcoming": "Upcoming Movies",
|
||||
"components.Discover.upcomingmovies": "Upcoming Movies",
|
||||
@@ -590,6 +623,8 @@
|
||||
"components.Settings.RadarrModal.servername": "Server Name",
|
||||
"components.Settings.RadarrModal.ssl": "Use SSL",
|
||||
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
|
||||
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
|
||||
"components.Settings.RadarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
|
||||
"components.Settings.RadarrModal.tags": "Tags",
|
||||
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
|
||||
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
|
||||
@@ -742,6 +777,8 @@
|
||||
"components.Settings.SonarrModal.servername": "Server Name",
|
||||
"components.Settings.SonarrModal.ssl": "Use SSL",
|
||||
"components.Settings.SonarrModal.syncEnabled": "Enable Scan",
|
||||
"components.Settings.SonarrModal.tagRequests": "Tag Requests",
|
||||
"components.Settings.SonarrModal.tagRequestsInfo": "Automatically add an additional tag with the requester's user ID & display name",
|
||||
"components.Settings.SonarrModal.tags": "Tags",
|
||||
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
|
||||
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
|
||||
@@ -1095,6 +1132,7 @@
|
||||
"i18n.cancel": "Cancel",
|
||||
"i18n.canceling": "Canceling…",
|
||||
"i18n.close": "Close",
|
||||
"i18n.collection": "Collection",
|
||||
"i18n.decline": "Decline",
|
||||
"i18n.declined": "Declined",
|
||||
"i18n.delete": "Delete",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import MovieDetails from '@app/components/MovieDetails';
|
||||
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
||||
import axios from 'axios';
|
||||
import type { NextPage } from 'next';
|
||||
import type { GetServerSideProps, NextPage } from 'next';
|
||||
|
||||
interface MoviePageProps {
|
||||
movie?: MovieDetailsType;
|
||||
@@ -11,25 +11,25 @@ const MoviePage: NextPage<MoviePageProps> = ({ movie }) => {
|
||||
return <MovieDetails movie={movie} />;
|
||||
};
|
||||
|
||||
MoviePage.getInitialProps = async (ctx) => {
|
||||
if (ctx.req) {
|
||||
const response = await axios.get<MovieDetailsType>(
|
||||
`http://localhost:${process.env.PORT || 5055}/api/v1/movie/${
|
||||
ctx.query.movieId
|
||||
}`,
|
||||
{
|
||||
headers: ctx.req?.headers?.cookie
|
||||
? { cookie: ctx.req.headers.cookie }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
export const getServerSideProps: GetServerSideProps<MoviePageProps> = async (
|
||||
ctx
|
||||
) => {
|
||||
const response = await axios.get<MovieDetailsType>(
|
||||
`http://localhost:${process.env.PORT || 5055}/api/v1/movie/${
|
||||
ctx.query.movieId
|
||||
}`,
|
||||
{
|
||||
headers: ctx.req?.headers?.cookie
|
||||
? { cookie: ctx.req.headers.cookie }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
return {
|
||||
props: {
|
||||
movie: response.data,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default MoviePage;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import TvDetails from '@app/components/TvDetails';
|
||||
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import type { NextPage } from 'next';
|
||||
import type { GetServerSideProps, NextPage } from 'next';
|
||||
|
||||
interface TvPageProps {
|
||||
tv?: TvDetailsType;
|
||||
@@ -11,25 +11,23 @@ const TvPage: NextPage<TvPageProps> = ({ tv }) => {
|
||||
return <TvDetails tv={tv} />;
|
||||
};
|
||||
|
||||
TvPage.getInitialProps = async (ctx) => {
|
||||
if (ctx.req) {
|
||||
const response = await axios.get<TvDetailsType>(
|
||||
`http://localhost:${process.env.PORT || 5055}/api/v1/tv/${
|
||||
ctx.query.tvId
|
||||
}`,
|
||||
{
|
||||
headers: ctx.req?.headers?.cookie
|
||||
? { cookie: ctx.req.headers.cookie }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
export const getServerSideProps: GetServerSideProps<TvPageProps> = async (
|
||||
ctx
|
||||
) => {
|
||||
const response = await axios.get<TvDetailsType>(
|
||||
`http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`,
|
||||
{
|
||||
headers: ctx.req?.headers?.cookie
|
||||
? { cookie: ctx.req.headers.cookie }
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
return {
|
||||
props: {
|
||||
tv: response.data,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default TvPage;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
body {
|
||||
@apply bg-gray-900;
|
||||
overscroll-behavior-y: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
code {
|
||||
@@ -73,6 +73,10 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
|
||||
}
|
||||
|
||||
.provider-icons {
|
||||
grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
@apply relative mt-6 mb-4 flex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user