feat: add content certification/age-rating filter (#1418)
* feat(api): add TMDB certifications endpoint and discover certification params re #501 * feat(discover): add certification/age-rating filter to movies and series Add generic and US-only certification selector components, update Discover FilterSlideover, add certification options to query constants re #501 * fix(certificationselector): fix linter warning from useEffect missing dependency * fix(jellyseerr-api.yml): prettier formatting * chore(translation keys): run pnpm i18n:extract * fix(certificationselector): change query destructure to Zod omit, fix translations, fix formatting * style: fix whitespace with prettier
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
StatusSelector,
|
||||
USCertificationSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
@@ -42,6 +43,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
status: 'Status',
|
||||
certification: 'Content Rating',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -190,6 +192,16 @@ const FilterSlideover = ({
|
||||
updateQueryParams('language', value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.certification)}
|
||||
</span>
|
||||
<USCertificationSelector
|
||||
type={type}
|
||||
certification={currentFilters.certification}
|
||||
onChange={(params) => {
|
||||
batchUpdateQueryParams(params);
|
||||
}}
|
||||
/>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.runtime)}
|
||||
</span>
|
||||
|
||||
@@ -109,6 +109,11 @@ export const QueryFilterOptions = z.object({
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
certification: z.string().optional(),
|
||||
certificationGte: z.string().optional(),
|
||||
certificationLte: z.string().optional(),
|
||||
certificationCountry: z.string().optional(),
|
||||
certificationMode: z.enum(['exact', 'range']).optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -192,6 +197,30 @@ export const prepareFilterValues = (
|
||||
filterValues.watchRegion = values.watchRegion;
|
||||
}
|
||||
|
||||
if (values.certification) {
|
||||
filterValues.certification = values.certification;
|
||||
}
|
||||
|
||||
if (values.certificationGte) {
|
||||
filterValues.certificationGte = values.certificationGte;
|
||||
}
|
||||
|
||||
if (values.certificationLte) {
|
||||
filterValues.certificationLte = values.certificationLte;
|
||||
}
|
||||
|
||||
if (values.certificationCountry) {
|
||||
filterValues.certificationCountry = values.certificationCountry;
|
||||
}
|
||||
|
||||
if (values.certificationMode) {
|
||||
filterValues.certificationMode = values.certificationMode;
|
||||
} else if (values.certification) {
|
||||
filterValues.certificationMode = 'exact';
|
||||
} else if (values.certificationGte || values.certificationLte) {
|
||||
filterValues.certificationMode = 'range';
|
||||
}
|
||||
|
||||
return filterValues;
|
||||
};
|
||||
|
||||
@@ -223,6 +252,20 @@ export const countActiveFilters = (filterValues: FilterOptions): number => {
|
||||
delete clonedFilters.watchRegion;
|
||||
}
|
||||
|
||||
if (
|
||||
clonedFilters.certification ||
|
||||
clonedFilters.certificationGte ||
|
||||
clonedFilters.certificationLte ||
|
||||
clonedFilters.certificationCountry
|
||||
) {
|
||||
totalCount += 1;
|
||||
delete clonedFilters.certification;
|
||||
delete clonedFilters.certificationGte;
|
||||
delete clonedFilters.certificationLte;
|
||||
delete clonedFilters.certificationCountry;
|
||||
}
|
||||
|
||||
delete clonedFilters.certificationMode;
|
||||
totalCount += Object.keys(clonedFilters).length;
|
||||
|
||||
return totalCount;
|
||||
|
||||
333
src/components/Selector/CertificationSelector.tsx
Normal file
333
src/components/Selector/CertificationSelector.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import type { Region } from '@server/lib/settings';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface Certification {
|
||||
certification: string;
|
||||
meaning?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
interface CertificationResponse {
|
||||
certifications: {
|
||||
[country: string]: Certification[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CertificationOption {
|
||||
value: string;
|
||||
label: string;
|
||||
certification?: string;
|
||||
}
|
||||
|
||||
interface CertificationSelectorProps {
|
||||
type: string;
|
||||
certificationCountry?: string;
|
||||
certification?: string;
|
||||
certificationGte?: string;
|
||||
certificationLte?: string;
|
||||
onChange: (params: {
|
||||
certificationCountry?: string;
|
||||
certification?: string;
|
||||
certificationGte?: string;
|
||||
certificationLte?: string;
|
||||
}) => void;
|
||||
showRange?: boolean;
|
||||
}
|
||||
|
||||
const messages = defineMessages('components.Selector.CertificationSelector', {
|
||||
selectCountry: 'Select a country',
|
||||
selectCertification: 'Select a certification',
|
||||
minRating: 'Minimum rating',
|
||||
maxRating: 'Maximum rating',
|
||||
noOptions: 'No options available',
|
||||
starttyping: 'Starting typing to search.',
|
||||
errorLoading: 'Failed to load certifications',
|
||||
});
|
||||
|
||||
const CertificationSelector: React.FC<CertificationSelectorProps> = ({
|
||||
type,
|
||||
certificationCountry,
|
||||
certification,
|
||||
certificationGte,
|
||||
certificationLte,
|
||||
showRange = false,
|
||||
onChange,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [selectedCountry, setSelectedCountry] =
|
||||
useState<CertificationOption | null>(
|
||||
certificationCountry
|
||||
? { value: certificationCountry, label: certificationCountry }
|
||||
: null
|
||||
);
|
||||
const [selectedCertification, setSelectedCertification] =
|
||||
useState<CertificationOption | null>(null);
|
||||
const [selectedCertificationGte, setSelectedCertificationGte] =
|
||||
useState<CertificationOption | null>(null);
|
||||
const [selectedCertificationLte, setSelectedCertificationLte] =
|
||||
useState<CertificationOption | null>(null);
|
||||
|
||||
const {
|
||||
data: certificationData,
|
||||
error: certificationError,
|
||||
isLoading: certificationLoading,
|
||||
} = useSWR<CertificationResponse>(`/api/v1/certifications/${type}`);
|
||||
|
||||
const { data: regionsData } = useSWR<Region[]>('/api/v1/regions');
|
||||
|
||||
// Get the country name from its code
|
||||
const getCountryName = useCallback(
|
||||
(countryCode: string): string => {
|
||||
const region = regionsData?.find(
|
||||
(region) => region.iso_3166_1 === countryCode
|
||||
);
|
||||
return region?.name || countryCode;
|
||||
},
|
||||
[regionsData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (certificationCountry && regionsData) {
|
||||
setSelectedCountry({
|
||||
value: certificationCountry,
|
||||
label: getCountryName(certificationCountry),
|
||||
});
|
||||
}
|
||||
}, [certificationCountry, regionsData, getCountryName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certificationData || !certificationCountry) return;
|
||||
|
||||
const certifications = (
|
||||
certificationData.certifications[certificationCountry] || []
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return a.certification.localeCompare(b.certification);
|
||||
})
|
||||
.map((cert) => ({
|
||||
value: cert.certification,
|
||||
label: `${cert.certification}${
|
||||
cert.meaning ? ` - ${cert.meaning}` : ''
|
||||
}`,
|
||||
certification: cert.certification,
|
||||
}));
|
||||
|
||||
if (certification) {
|
||||
setSelectedCertification(
|
||||
certifications.find((c) => c.value === certification) || null
|
||||
);
|
||||
}
|
||||
|
||||
if (certificationGte) {
|
||||
setSelectedCertificationGte(
|
||||
certifications.find((c) => c.value === certificationGte) || null
|
||||
);
|
||||
}
|
||||
|
||||
if (certificationLte) {
|
||||
setSelectedCertificationLte(
|
||||
certifications.find((c) => c.value === certificationLte) || null
|
||||
);
|
||||
}
|
||||
}, [
|
||||
certificationData,
|
||||
certificationCountry,
|
||||
certification,
|
||||
certificationGte,
|
||||
certificationLte,
|
||||
]);
|
||||
|
||||
if (certificationError) {
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
{intl.formatMessage(messages.errorLoading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (certificationLoading || !certificationData) {
|
||||
return <SmallLoadingSpinner />;
|
||||
}
|
||||
|
||||
const loadCountryOptions = async (inputValue: string) => {
|
||||
if (!certificationData || !regionsData) return [];
|
||||
|
||||
return Object.keys(certificationData.certifications)
|
||||
.filter(
|
||||
(code) =>
|
||||
certificationData.certifications[code] &&
|
||||
certificationData.certifications[code].length > 0 &&
|
||||
(code.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
getCountryName(code)
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()))
|
||||
)
|
||||
.sort((a, b) => getCountryName(a).localeCompare(getCountryName(b)))
|
||||
.map((code) => ({
|
||||
value: code,
|
||||
label: getCountryName(code),
|
||||
}));
|
||||
};
|
||||
|
||||
const loadCertificationOptions = async (inputValue: string) => {
|
||||
if (!certificationData || !certificationCountry) return [];
|
||||
|
||||
return (certificationData.certifications[certificationCountry] || [])
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return a.certification.localeCompare(b.certification);
|
||||
})
|
||||
.map((cert) => ({
|
||||
value: cert.certification,
|
||||
label: `${cert.certification}${
|
||||
cert.meaning ? ` - ${cert.meaning}` : ''
|
||||
}`,
|
||||
certification: cert.certification,
|
||||
}))
|
||||
.filter((cert) =>
|
||||
cert.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const handleCountryChange = (option: CertificationOption | null) => {
|
||||
setSelectedCountry(option);
|
||||
setSelectedCertification(null);
|
||||
setSelectedCertificationGte(null);
|
||||
setSelectedCertificationLte(null);
|
||||
|
||||
onChange({
|
||||
certificationCountry: option?.value,
|
||||
certification: undefined,
|
||||
certificationGte: undefined,
|
||||
certificationLte: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCertificationChange = (option: CertificationOption | null) => {
|
||||
setSelectedCertification(option);
|
||||
|
||||
onChange({
|
||||
certificationCountry,
|
||||
certification: option?.value,
|
||||
certificationGte: undefined,
|
||||
certificationLte: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMinCertificationChange = (option: CertificationOption | null) => {
|
||||
setSelectedCertificationGte(option);
|
||||
|
||||
onChange({
|
||||
certificationCountry,
|
||||
certification: undefined,
|
||||
certificationGte: option?.value,
|
||||
certificationLte: certificationLte,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMaxCertificationChange = (option: CertificationOption | null) => {
|
||||
setSelectedCertificationLte(option);
|
||||
|
||||
onChange({
|
||||
certificationCountry,
|
||||
certification: undefined,
|
||||
certificationGte: certificationGte,
|
||||
certificationLte: option?.value,
|
||||
});
|
||||
};
|
||||
|
||||
const formatCertificationLabel = (
|
||||
option: CertificationOption,
|
||||
{ context }: { context: string }
|
||||
) => {
|
||||
if (context === 'value') {
|
||||
return option.certification || option.value;
|
||||
}
|
||||
// Show the full label with description in the menu
|
||||
return option.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCountryOptions}
|
||||
value={selectedCountry}
|
||||
onChange={handleCountryChange}
|
||||
placeholder={intl.formatMessage(messages.selectCountry)}
|
||||
isClearable
|
||||
noOptionsMessage={({ inputValue }) =>
|
||||
inputValue === ''
|
||||
? intl.formatMessage(messages.starttyping)
|
||||
: intl.formatMessage(messages.noOptions)
|
||||
}
|
||||
/>
|
||||
|
||||
{certificationCountry && !showRange && (
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCertificationOptions}
|
||||
value={selectedCertification}
|
||||
onChange={handleCertificationChange}
|
||||
placeholder={intl.formatMessage(messages.selectCertification)}
|
||||
formatOptionLabel={formatCertificationLabel}
|
||||
isClearable
|
||||
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{certificationCountry && showRange && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex-1">
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCertificationOptions}
|
||||
value={selectedCertificationGte}
|
||||
onChange={handleMinCertificationChange}
|
||||
placeholder={intl.formatMessage(messages.minRating)}
|
||||
formatOptionLabel={formatCertificationLabel}
|
||||
isClearable
|
||||
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AsyncSelect
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={loadCertificationOptions}
|
||||
value={selectedCertificationLte}
|
||||
onChange={handleMaxCertificationChange}
|
||||
placeholder={intl.formatMessage(messages.maxRating)}
|
||||
formatOptionLabel={formatCertificationLabel}
|
||||
isClearable
|
||||
noOptionsMessage={() => intl.formatMessage(messages.noOptions)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificationSelector;
|
||||
87
src/components/Selector/USCertificationSelector.tsx
Normal file
87
src/components/Selector/USCertificationSelector.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface USCertificationSelectorProps {
|
||||
type: string;
|
||||
certification?: string;
|
||||
onChange: (params: {
|
||||
certificationCountry?: string;
|
||||
certification?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const US_MOVIE_CERTIFICATIONS = ['NR', 'G', 'PG', 'PG-13', 'R', 'NC-17'];
|
||||
const US_TV_CERTIFICATIONS = [
|
||||
'NR',
|
||||
'TV-Y',
|
||||
'TV-Y7',
|
||||
'TV-G',
|
||||
'TV-PG',
|
||||
'TV-14',
|
||||
'TV-MA',
|
||||
];
|
||||
|
||||
const USCertificationSelector: React.FC<USCertificationSelectorProps> = ({
|
||||
type,
|
||||
certification,
|
||||
onChange,
|
||||
}) => {
|
||||
const [selectedRatings, setSelectedRatings] = useState<string[]>(() =>
|
||||
certification ? certification.split('|') : []
|
||||
);
|
||||
|
||||
const certifications =
|
||||
type === 'movie' ? US_MOVIE_CERTIFICATIONS : US_TV_CERTIFICATIONS;
|
||||
|
||||
useEffect(() => {
|
||||
if (certification) {
|
||||
setSelectedRatings(certification.split('|'));
|
||||
} else {
|
||||
setSelectedRatings([]);
|
||||
}
|
||||
}, [certification]);
|
||||
|
||||
const toggleRating = (rating: string) => {
|
||||
setSelectedRatings((prevSelected) => {
|
||||
let newSelected;
|
||||
|
||||
if (prevSelected.includes(rating)) {
|
||||
newSelected = prevSelected.filter((r) => r !== rating);
|
||||
} else {
|
||||
newSelected = [...prevSelected, rating];
|
||||
}
|
||||
|
||||
const newCertification =
|
||||
newSelected.length > 0 ? newSelected.join('|') : undefined;
|
||||
|
||||
onChange({
|
||||
certificationCountry: 'US',
|
||||
certification: newCertification,
|
||||
});
|
||||
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{certifications.map((rating) => (
|
||||
<button
|
||||
key={rating}
|
||||
onClick={() => toggleRating(rating)}
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||
selectedRatings.includes(rating)
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{rating}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default USCertificationSelector;
|
||||
@@ -631,3 +631,5 @@ export const UserSelector = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { default as USCertificationSelector } from './USCertificationSelector';
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"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.certification": "Content Rating",
|
||||
"components.Discover.FilterSlideover.clearfilters": "Clear Active Filters",
|
||||
"components.Discover.FilterSlideover.filters": "Filters",
|
||||
"components.Discover.FilterSlideover.firstAirDate": "First Air Date",
|
||||
@@ -103,7 +104,6 @@
|
||||
"components.Discover.StudioSlider.studios": "Studios",
|
||||
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
||||
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
||||
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||
"components.Discover.createnewslider": "Create New Slider",
|
||||
"components.Discover.customizediscover": "Customize Discover",
|
||||
"components.Discover.discover": "Discover",
|
||||
@@ -137,6 +137,7 @@
|
||||
"components.Discover.upcomingtv": "Upcoming Series",
|
||||
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
|
||||
"components.Discover.updatesuccess": "Updated discover customization settings.",
|
||||
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||
"components.DownloadBlock.estimatedtime": "Estimated {time}",
|
||||
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
|
||||
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
|
||||
@@ -583,6 +584,13 @@
|
||||
"components.ResetPassword.validationpasswordrequired": "You must provide a password",
|
||||
"components.Search.search": "Search",
|
||||
"components.Search.searchresults": "Search Results",
|
||||
"components.Selector.CertificationSelector.errorLoading": "Failed to load certifications",
|
||||
"components.Selector.CertificationSelector.maxRating": "Maximum rating",
|
||||
"components.Selector.CertificationSelector.minRating": "Minimum rating",
|
||||
"components.Selector.CertificationSelector.noOptions": "No options available",
|
||||
"components.Selector.CertificationSelector.selectCertification": "Select a certification",
|
||||
"components.Selector.CertificationSelector.selectCountry": "Select a country",
|
||||
"components.Selector.CertificationSelector.starttyping": "Starting typing to search.",
|
||||
"components.Selector.canceled": "Canceled",
|
||||
"components.Selector.ended": "Ended",
|
||||
"components.Selector.inProduction": "In Production",
|
||||
@@ -1199,7 +1207,7 @@
|
||||
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign In",
|
||||
"components.Setup.signin": "Sign in to your account",
|
||||
"components.Setup.signinMessage": "Get started by signing in",
|
||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
|
||||
Reference in New Issue
Block a user