* 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
334 lines
9.7 KiB
TypeScript
334 lines
9.7 KiB
TypeScript
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;
|