diff --git a/src/components/BlacktagsSelector/index.tsx b/src/components/BlacktagsSelector/index.tsx new file mode 100644 index 000000000..fbe8f98b9 --- /dev/null +++ b/src/components/BlacktagsSelector/index.tsx @@ -0,0 +1,430 @@ +import Modal from '@app/components/Common/Modal'; +import Tooltip from '@app/components/Common/Tooltip'; +import { encodeURIExtraParams } from '@app/hooks/useDiscover'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import { + ArrowDownIcon, + ClipboardDocumentIcon, +} from '@heroicons/react/24/solid'; +import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces'; +import type { Keyword } from '@server/models/common'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select'; +import { components } from 'react-select'; +import AsyncSelect from 'react-select/async'; +import { useToasts } from 'react-toast-notifications'; +import useClipboard from 'react-use-clipboard'; + +const messages = defineMessages('components.Settings', { + copyBlacktags: 'Copied blacktags to clipboard.', + copyBlacktagsTip: 'Copy blacktag configuration', + importBlacktags: '', + importBlacktagsTip: 'Import blacktag configuration', + clearBlacktagsConfirm: 'Are you sure you want to clear the blacktags?', + yes: 'Yes', + no: 'No', + searchKeywords: 'Search keywords…', + starttyping: 'Starting typing to search.', + nooptions: 'No results.', + blacktagImportTitle: 'Import Blacktag Configuration', + blacktagImportInstructions: 'Paste blacktag configuration below.', + valueRequired: 'You must provide a value.', + noSpecialCharacters: + 'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.', + invalidKeyword: '{keywordId} is not a TMDB keyword.', +}); + +type SingleVal = { + label: string; + value: number; +}; + +type BlacktagsSelectorProps = { + defaultValue?: string; + onChange: (value: MultiValue | null) => void; + formRef?: React.Ref; +}; + +const BlacktagsSelector = ({ + defaultValue, + onChange, +}: BlacktagsSelectorProps) => { + const [value, setValue] = useState(defaultValue); + const [selectorValue, setSelectorValue] = + useState | null>(null); + + const localOnChange = useCallback( + (value: MultiValue | null) => { + setSelectorValue(value); + setValue(value?.map((v) => v.value).join(',')); + onChange(value); + }, + [setSelectorValue, setValue, onChange] + ); + + return ( + <> + + + + + + ); +}; + +type BaseSelectorMultiProps = { + defaultValue?: string; + value: MultiValue | null; + onChange: (value: MultiValue | null) => void; + components?: Partial; +}; + +const ControlledKeywordSelector = ({ + defaultValue, + onChange, + components, + value, +}: BaseSelectorMultiProps) => { + const intl = useIntl(); + + useEffect(() => { + const loadDefaultKeywords = async (): Promise => { + if (!defaultValue) { + return; + } + + const keywords = await Promise.all( + defaultValue.split(',').map(async (keywordId) => { + const res = await fetch(`/api/v1/keyword/${keywordId}`); + if (!res.ok) { + throw new Error('Network response was not ok'); + } + const keyword: Keyword = await res.json(); + + return keyword; + }) + ); + + onChange( + keywords.map((keyword) => ({ + label: keyword.name, + value: keyword.id, + })) + ); + }; + + loadDefaultKeywords(); + }, [defaultValue, onChange]); + + const loadKeywordOptions = async (inputValue: string) => { + const res = await fetch( + `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}` + ); + if (!res.ok) { + throw new Error('Network response was not ok'); + } + const results: TmdbKeywordSearchResponse = await res.json(); + + return results.results.map((result) => ({ + label: result.name, + value: result.id, + })); + }; + + return ( + + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.nooptions) + } + value={value} + loadOptions={loadKeywordOptions} + placeholder={intl.formatMessage(messages.searchKeywords)} + onChange={onChange} + components={components} + /> + ); +}; + +type BlacktagsCopyButtonProps = { + value: string; +}; + +const BlacktagsCopyButton = ({ value }: BlacktagsCopyButtonProps) => { + const intl = useIntl(); + const [isCopied, setCopied] = useClipboard(value, { + successDuration: 1000, + }); + const { addToast } = useToasts(); + + useEffect(() => { + if (isCopied) { + addToast(intl.formatMessage(messages.copyBlacktags), { + appearance: 'info', + autoDismiss: true, + }); + } + }, [isCopied, addToast, intl]); + + return ( + + + + ); +}; + +type BlacktagsImportButton = { + setSelector: (value: MultiValue) => void; +}; + +const BlacktagsImportButton = ({ setSelector }: BlacktagsImportButton) => { + const [show, setShow] = useState(false); + const formRef = useRef(null); + const intl = useIntl(); + + const onConfirm = useCallback(async () => { + if (formRef.current) { + if (await formRef.current.submitForm()) { + setShow(false); + } + } + }, []); + + const onClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setShow(true); + }, []); + + return ( + <> + + setShow(false)} + > + + + + + + + + + ); +}; + +type BlacktagImportFormProps = BlacktagsImportButton; + +const BlacktagImportForm = forwardRef< + Partial, + BlacktagImportFormProps +>((props, ref) => { + const { setSelector } = props; + const intl = useIntl(); + const [formValue, setFormValue] = useState(''); + const [errors, setErrors] = useState([]); + + useImperativeHandle(ref, () => ({ + submitForm: handleSubmit, + formValue, + })); + + const validate = async () => { + if (formValue.length === 0) { + setErrors([intl.formatMessage(messages.valueRequired)]); + return false; + } + + if (!/^(?:\d+,)*\d+$/.test(formValue)) { + setErrors([intl.formatMessage(messages.noSpecialCharacters)]); + return false; + } + + const keywords = await Promise.allSettled( + formValue.split(',').map(async (keywordId) => { + const res = await fetch(`/api/v1/keyword/${keywordId}`); + if (!res.ok) { + throw intl.formatMessage(messages.invalidKeyword, { keywordId }); + } + + const keyword: Keyword = await res.json(); + return { + label: keyword.name, + value: keyword.id, + }; + }) + ); + + const failures = keywords.filter((res) => res.status === 'rejected'); + if (failures.length > 0) { + setErrors(failures.map((failure) => `${failure.reason}`)); + return false; + } + + setSelector( + (keywords as PromiseFulfilledResult[]).map((p) => p.value) + ); + + setErrors([]); + return true; + }; + + const handleSubmit = validate; + + return ( +
+
+ +