mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00

* Added: Plaintext untouched * Revert "Added: Plaintext untouched" This reverts commit e6ceaec5f4776cfc8335ae2c02e1caa4a2bbb0bc. * Added: skipCleanSanitize * TS definition for List object doesn't yet know about the new skip_clean_sanitize property * Update: ListForms.tsx with the bypass option * Update: Database internals for skip_clean_sanitize * Fix: Snake case
1020 lines
39 KiB
TypeScript
1020 lines
39 KiB
TypeScript
/*
|
|
* Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors.
|
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
|
*/
|
|
|
|
import { Fragment, JSX, useEffect, useRef, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import Select from "react-select";
|
|
import {
|
|
Field,
|
|
FieldProps,
|
|
Form,
|
|
Formik,
|
|
FormikErrors,
|
|
FormikValues,
|
|
useFormikContext
|
|
} from "formik";
|
|
import {
|
|
Dialog,
|
|
DialogPanel,
|
|
DialogTitle,
|
|
Listbox,
|
|
ListboxButton,
|
|
ListboxOption,
|
|
ListboxOptions,
|
|
Transition,
|
|
TransitionChild
|
|
} from "@headlessui/react";
|
|
import { CheckIcon, ChevronUpDownIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
|
|
|
import { APIClient } from "@api/APIClient";
|
|
import { ListKeys } from "@api/query_keys";
|
|
import { toast } from "@components/hot-toast";
|
|
import Toast from "@components/notifications/Toast";
|
|
import * as common from "@components/inputs/common";
|
|
import {
|
|
MultiSelectOption,
|
|
PasswordFieldWide,
|
|
SwitchGroupWide,
|
|
TextFieldWide
|
|
} from "@components/inputs";
|
|
import {
|
|
ListsMDBListOptions,
|
|
ListsMetacriticOptions,
|
|
ListsTraktOptions,
|
|
ListsAniListOptions,
|
|
ListTypeOptions,
|
|
OptionBasicTyped
|
|
} from "@domain/constants";
|
|
import { DEBUG } from "@components/debug";
|
|
import {
|
|
DownloadClientsArrTagsQueryOptions,
|
|
DownloadClientsQueryOptions,
|
|
FiltersGetAllQueryOptions
|
|
} from "@api/queries";
|
|
import { classNames, sleep } from "@utils";
|
|
import {
|
|
ListFilterMultiSelectField,
|
|
SelectFieldBasic,
|
|
SelectFieldCreatable
|
|
} from "@components/inputs/select_wide";
|
|
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
|
|
import { MultiSelect as RMSC } from "react-multi-select-component";
|
|
import { useToggle } from "@hooks/hooks.ts";
|
|
import { DeleteModal } from "@components/modals";
|
|
import {DocsLink} from "@components/ExternalLink.tsx";
|
|
|
|
interface ListAddFormValues {
|
|
name: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface AddFormProps {
|
|
isOpen: boolean;
|
|
toggle: () => void;
|
|
}
|
|
|
|
export function ListAddForm({ isOpen, toggle }: AddFormProps) {
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: clients } = useQuery(DownloadClientsQueryOptions());
|
|
|
|
const filterQuery = useQuery(FiltersGetAllQueryOptions());
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (list: List) => APIClient.lists.store(list),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
|
|
|
toast.custom((t) => <Toast type="success" body="List added!" t={t}/>);
|
|
toggle();
|
|
},
|
|
onError: () => {
|
|
toast.custom((t) => <Toast type="error" body="List could not be added" t={t}/>);
|
|
}
|
|
});
|
|
|
|
const onSubmit = (formData: unknown) => createMutation.mutate(formData as List);
|
|
|
|
const validate = (values: ListAddFormValues) => {
|
|
const errors = {} as FormikErrors<FormikValues>;
|
|
if (!values.name)
|
|
errors.name = "Required";
|
|
|
|
return errors;
|
|
};
|
|
|
|
return (
|
|
<Transition show={isOpen} as={Fragment}>
|
|
<Dialog
|
|
as="div"
|
|
static
|
|
className="fixed inset-0 overflow-hidden"
|
|
open={isOpen}
|
|
onClose={toggle}
|
|
>
|
|
<div className="absolute inset-0 overflow-hidden">
|
|
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
|
|
<TransitionChild
|
|
as={Fragment}
|
|
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
|
enterFrom="translate-x-full"
|
|
enterTo="translate-x-0"
|
|
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
|
leaveFrom="translate-x-0"
|
|
leaveTo="translate-x-full"
|
|
>
|
|
<div className="w-screen max-w-2xl">
|
|
<Formik
|
|
enableReinitialize={true}
|
|
initialValues={{
|
|
enabled: true,
|
|
type: "",
|
|
name: "",
|
|
client_id: 0,
|
|
url: "",
|
|
headers: [],
|
|
api_key: "",
|
|
filters: [],
|
|
match_release: false,
|
|
tags_included: [],
|
|
tags_excluded: [],
|
|
include_unmonitored: false,
|
|
include_alternate_titles: false,
|
|
skip_clean_sanitize: false,
|
|
}}
|
|
onSubmit={onSubmit}
|
|
validate={validate}
|
|
>
|
|
{({ values }) => (
|
|
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
|
|
<div className="flex-1">
|
|
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
|
|
<div className="flex items-start justify-between space-x-3">
|
|
<div className="space-y-1">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Add List
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-200">
|
|
Auto update filters from lists and arrs.
|
|
</p>
|
|
</div>
|
|
<div className="h-7 flex items-center">
|
|
<button
|
|
type="button"
|
|
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500"
|
|
onClick={toggle}
|
|
>
|
|
<span className="sr-only">Close panel</span>
|
|
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
|
<TextFieldWide
|
|
name="name"
|
|
label="Name"
|
|
required={true}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
|
<div>
|
|
<label htmlFor="type" className="block text-sm font-medium text-gray-900 dark:text-white"
|
|
>
|
|
Type
|
|
</label>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Field name="type" type="select">
|
|
{({
|
|
field,
|
|
form: { setFieldValue }
|
|
}: FieldProps) => (
|
|
<Select
|
|
{...field}
|
|
isClearable={true}
|
|
isSearchable={true}
|
|
components={{
|
|
Input: common.SelectInput,
|
|
Control: common.SelectControl,
|
|
Menu: common.SelectMenu,
|
|
Option: common.SelectOption,
|
|
IndicatorSeparator: common.IndicatorSeparator,
|
|
DropdownIndicator: common.DropdownIndicator
|
|
}}
|
|
placeholder="Choose a type"
|
|
styles={{
|
|
singleValue: (base) => ({
|
|
...base,
|
|
color: "unset"
|
|
})
|
|
}}
|
|
theme={(theme) => ({
|
|
...theme,
|
|
spacing: {
|
|
...theme.spacing,
|
|
controlHeight: 30,
|
|
baseUnit: 2
|
|
}
|
|
})}
|
|
value={field?.value && field.value.value}
|
|
onChange={(newValue: unknown) => {
|
|
const option = newValue as { value: string };
|
|
setFieldValue(field.name, option?.value ?? "");
|
|
}}
|
|
options={ListTypeOptions}
|
|
/>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
<SwitchGroupWide name="enabled" label="Enabled"/>
|
|
</div>
|
|
|
|
<ListTypeForm listType={values.type as ListType} clients={clients ?? []}/>
|
|
|
|
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Filters
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Select filters to update for this list.
|
|
</p>
|
|
</div>
|
|
|
|
<ListFilterMultiSelectField
|
|
name="filters"
|
|
label="Filters"
|
|
required={true}
|
|
options={filterQuery.data?.map(f => ({ value: f.id, label: f.name })) ?? []}
|
|
/>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4 sm:px-6">
|
|
<div className="space-x-3 flex justify-end">
|
|
<button
|
|
type="button"
|
|
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-xs text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
|
onClick={toggle}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<SubmitButton isPending={createMutation.isPending} isError={createMutation.isError} isSuccess={createMutation.isSuccess} />
|
|
</div>
|
|
</div>
|
|
|
|
<DEBUG values={values}/>
|
|
</Form>
|
|
)}
|
|
</Formik>
|
|
</div>
|
|
</TransitionChild>
|
|
</DialogPanel>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
interface UpdateFormProps<T> {
|
|
isOpen: boolean;
|
|
toggle: () => void;
|
|
data: T;
|
|
}
|
|
|
|
export function ListUpdateForm({ isOpen, toggle, data }: UpdateFormProps<List>) {
|
|
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
|
|
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const clientsQuery = useQuery(DownloadClientsQueryOptions());
|
|
const filterQuery = useQuery(FiltersGetAllQueryOptions());
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (list: List) => APIClient.lists.update(list),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
|
|
|
toast.custom((t) => <Toast type="success" body={`${data.name} was updated successfully`} t={t}/>);
|
|
|
|
sleep(1500);
|
|
toggle();
|
|
}
|
|
});
|
|
|
|
const onSubmit = (formData: unknown) => mutation.mutate(formData as List);
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (listID: number) => APIClient.lists.delete(listID),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
|
|
|
|
toast.custom((t) => <Toast type="success" body={`${data.name} was deleted.`} t={t}/>);
|
|
}
|
|
});
|
|
|
|
const deleteAction = () => deleteMutation.mutate(data.id);
|
|
|
|
return (
|
|
<Transition show={isOpen} as={Fragment}>
|
|
<Dialog
|
|
as="div"
|
|
static
|
|
className="fixed inset-0 overflow-hidden"
|
|
open={isOpen}
|
|
onClose={toggle}
|
|
>
|
|
{deleteAction && (
|
|
<DeleteModal
|
|
isOpen={deleteModalIsOpen}
|
|
isLoading={false}
|
|
toggle={toggleDeleteModal}
|
|
buttonRef={cancelModalButtonRef}
|
|
deleteAction={deleteAction}
|
|
title={`Remove ${data.name}`}
|
|
text={`Are you sure you want to remove this ${data.name}? This action cannot be undone.`}
|
|
/>
|
|
)}
|
|
<div className="absolute inset-0 overflow-hidden">
|
|
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
|
|
<TransitionChild
|
|
as={Fragment}
|
|
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
|
enterFrom="translate-x-full"
|
|
enterTo="translate-x-0"
|
|
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
|
leaveFrom="translate-x-0"
|
|
leaveTo="translate-x-full"
|
|
>
|
|
<div className="w-screen max-w-2xl">
|
|
<Formik
|
|
enableReinitialize={true}
|
|
initialValues={{
|
|
id: data.id,
|
|
enabled: data.enabled,
|
|
type: data.type,
|
|
name: data.name,
|
|
client_id: data.client_id,
|
|
url: data.url,
|
|
headers: data.headers || [],
|
|
api_key: data.api_key,
|
|
filters: data.filters,
|
|
match_release: data.match_release,
|
|
tags_included: data.tags_included,
|
|
tags_excluded: data.tags_excluded,
|
|
include_unmonitored: data.include_unmonitored,
|
|
include_alternate_titles: data.include_alternate_titles,
|
|
skip_clean_sanitize: data.skip_clean_sanitize,
|
|
}}
|
|
onSubmit={onSubmit}
|
|
// validate={validate}
|
|
>
|
|
{({ values }) => (
|
|
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
|
|
<div className="flex-1">
|
|
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
|
|
<div className="flex items-start justify-between space-x-3">
|
|
<div className="space-y-1">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Update List
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-200">
|
|
Auto update filters from lists and arrs.
|
|
</p>
|
|
</div>
|
|
<div className="h-7 flex items-center">
|
|
<button
|
|
type="button"
|
|
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-hidden focus:ring-2 focus:ring-blue-500"
|
|
onClick={toggle}
|
|
>
|
|
<span className="sr-only">Close panel</span>
|
|
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
|
|
|
<TextFieldWide name="name" label="Name" required={true}/>
|
|
|
|
<TextFieldWide name="type" label="Type" required={true} disabled={true} />
|
|
|
|
<SwitchGroupWide name="enabled" label="Enabled"/>
|
|
|
|
<div className="space-y-2 divide-y divide-gray-200 dark:divide-gray-700">
|
|
<ListTypeForm listType={values.type} clients={clientsQuery.data ?? []}/>
|
|
</div>
|
|
|
|
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Filters
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Select filters to update for this list.
|
|
</p>
|
|
</div>
|
|
|
|
<ListFilterMultiSelectField
|
|
name="filters"
|
|
label="Filters"
|
|
required={true}
|
|
options={filterQuery.data?.map(f => ({ value: f.id, label: f.name })) ?? []}
|
|
/>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="space-x-3 flex justify-between">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-white bg-red-100 dark:bg-red-700 hover:bg-red-200 dark:hover:bg-red-600 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
|
|
onClick={toggleDeleteModal}
|
|
>
|
|
Remove
|
|
</button>
|
|
<div className="flex space-x-3">
|
|
<button
|
|
type="button"
|
|
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-xs text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
|
onClick={toggle}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<SubmitButton isPending={mutation.isPending} isError={mutation.isError} isSuccess={mutation.isSuccess} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DEBUG values={values}/>
|
|
</Form>
|
|
)}
|
|
</Formik>
|
|
</div>
|
|
</TransitionChild>
|
|
</DialogPanel>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
);
|
|
}
|
|
|
|
interface SubmitButtonProps {
|
|
isPending?: boolean;
|
|
isError?: boolean;
|
|
isSuccess?: boolean;
|
|
}
|
|
|
|
const SubmitButton = (props: SubmitButtonProps) => {
|
|
return (
|
|
<button
|
|
type="submit"
|
|
className={classNames(
|
|
// isTestSuccessful
|
|
// ? "text-green-500 border-green-500 bg-green-50"
|
|
// : isError
|
|
// ? "text-red-500 border-red-500 bg-red-50"
|
|
// : "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-rose-700 active:bg-rose-700",
|
|
props.isPending ? "cursor-not-allowed" : "",
|
|
"mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-xs text-sm transition ease-in-out duration-150 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-blue-700 active:bg-blue-700"
|
|
)}
|
|
>
|
|
{props.isPending ? (
|
|
<>
|
|
<svg
|
|
className="animate-spin h-5 w-5 text-green-500"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
></circle>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
|
|
<span className="pl-2">Saving..</span>
|
|
</>
|
|
) : (
|
|
<span>Save</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface ListTypeFormProps {
|
|
listID?: number;
|
|
listType: ListType;
|
|
clients: DownloadClient[];
|
|
}
|
|
|
|
const ListTypeForm = (props: ListTypeFormProps) => {
|
|
const { setFieldValue } = useFormikContext();
|
|
const [prevActionType, setPrevActionType] = useState<string | null>(null);
|
|
const { listType } = props;
|
|
|
|
useEffect(() => {
|
|
if (prevActionType !== null && prevActionType !== listType && ListTypeOptions.map(l => l.value).includes(listType)) {
|
|
// Reset the client_id field value
|
|
setFieldValue('client_id', 0);
|
|
// Reset the url
|
|
setFieldValue('url', '');
|
|
}
|
|
|
|
setPrevActionType(listType);
|
|
}, [listType, prevActionType, setFieldValue]);
|
|
|
|
switch (listType) {
|
|
case "RADARR":
|
|
return <ListTypeArr {...props} />;
|
|
case "SONARR":
|
|
return <ListTypeArr {...props} />;
|
|
case "LIDARR":
|
|
return <ListTypeArr {...props} />;
|
|
case "READARR":
|
|
return <ListTypeArr {...props} />;
|
|
case "WHISPARR":
|
|
return <ListTypeArr {...props} />;
|
|
case "TRAKT":
|
|
return <ListTypeTrakt />;
|
|
case "STEAM":
|
|
return <ListTypeSteam />;
|
|
case "METACRITIC":
|
|
return <ListTypeMetacritic />;
|
|
case "MDBLIST":
|
|
return <ListTypeMDBList />;
|
|
case "PLAINTEXT":
|
|
return <ListTypePlainText />;
|
|
case "ANILIST":
|
|
return <ListTypeAniList />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const FilterOptionCheckBoxes = (props: ListTypeFormProps) => {
|
|
switch (props.listType) {
|
|
case "RADARR":
|
|
case "SONARR":
|
|
return (
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
|
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
|
|
<SwitchGroupWide name="include_alternate_titles" label="Include Alternate Titles" description="Include alternate titles in the filter." />
|
|
</fieldset>
|
|
);
|
|
case "LIDARR":
|
|
case "WHISPARR":
|
|
case "READARR":
|
|
return (
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
|
|
</fieldset>
|
|
);
|
|
case "PLAINTEXT":
|
|
return (
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="skip_clean_sanitize" label="Bypass the cleanup and sanitization and use the list as-is" description="By default, titles are automatically sanitized and checked for unusual characters." />
|
|
</fieldset>
|
|
);
|
|
}
|
|
}
|
|
|
|
function ListTypeArr({ listType, clients }: ListTypeFormProps) {
|
|
const { values } = useFormikContext<List>();
|
|
|
|
useEffect(() => {
|
|
}, [values.client_id]);
|
|
|
|
const arrTagsQuery = useQuery(DownloadClientsArrTagsQueryOptions(values.client_id));
|
|
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Update filters from titles in Radarr, Sonarr, Lidarr, Readarr, or Whisparr.
|
|
</p>
|
|
</div>
|
|
|
|
<DownloadClientSelectCustom
|
|
name={`client_id`}
|
|
clients={clients}
|
|
clientType={listType}
|
|
/>
|
|
|
|
{values.client_id > 0 && (values.type === "RADARR" || values.type == "SONARR") && (
|
|
<>
|
|
<ListArrTagsMultiSelectField name="tags_included" label="Tags Included" options={arrTagsQuery.data?.map(f => ({
|
|
value: f.label,
|
|
label: f.label
|
|
})) ?? []}/>
|
|
|
|
<ListArrTagsMultiSelectField name="tags_excluded" label="Tags Excluded" options={arrTagsQuery.data?.map(f => ({
|
|
value: f.label,
|
|
label: f.label
|
|
})) ?? []}/>
|
|
</>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<FilterOptionCheckBoxes listType={listType} clients={[]}/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ListTypeTrakt() {
|
|
const { values } = useFormikContext<List>();
|
|
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source list
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Use a Trakt list or one of the default autobrr hosted lists.
|
|
</p>
|
|
</div>
|
|
|
|
<SelectFieldCreatable
|
|
name="url"
|
|
label="List URL"
|
|
help="Default Trakt lists. Override with your own."
|
|
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
|
/>
|
|
|
|
{!values.url.startsWith("https://api.autobrr.com/") && (
|
|
<PasswordFieldWide
|
|
name="api_key"
|
|
label="API Key"
|
|
help="Trakt API Key. Required for private lists."
|
|
/>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ListTypeAniList() {
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4 space-y-1">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source list
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Use an AniList list from one of the default autobrr hosted lists.
|
|
</p>
|
|
</div>
|
|
|
|
<SelectFieldBasic
|
|
name="url"
|
|
label="List URL"
|
|
options={ListsAniListOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
|
/>
|
|
|
|
<div className="space-y-1">
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ListTypePlainText() {
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source list
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Use a plain text list with one item per line.
|
|
</p>
|
|
</div>
|
|
|
|
<TextFieldWide
|
|
name="url"
|
|
label="List URL"
|
|
help="URL to a plain text file with one item per line"
|
|
placeholder="https://example.com/list.txt"
|
|
tooltip={
|
|
<div>
|
|
<p>Plaintext list can read from both http urls and local files on disk.</p>
|
|
<br />
|
|
<p>Remote: https://service.com/file.txt</p>
|
|
<br />
|
|
<p>Local: file:///home/username/file.txt</p>
|
|
<DocsLink href="https://autobrr.com/filters/lists" />
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="space-y-1">
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
|
</fieldset>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="skip_clean_sanitize" label="Bypass the cleanup and sanitization and use the list as-is" description="By default, titles are automatically sanitized and checked for unusual characters." />
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ListTypeSteam() {
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source list
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Follow Steam wishlists.
|
|
</p>
|
|
</div>
|
|
|
|
<TextFieldWide name="url" label="URL" help={"Steam Wishlist URL"} placeholder="https://store.steampowered.com/wishlist/id/USERNAME/wishlistdata"/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ListTypeMetacritic() {
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source list
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Use a Metacritic list or one of the default autobrr hosted lists.
|
|
</p>
|
|
</div>
|
|
|
|
<SelectFieldCreatable
|
|
name="url"
|
|
label="List URL"
|
|
help="Metacritic lists. Override with your own."
|
|
options={ListsMetacriticOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
|
/>
|
|
|
|
<div className="space-y-1">
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ListTypeMDBList() {
|
|
return (
|
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
|
<div className="px-4">
|
|
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
|
|
Source list
|
|
</DialogTitle>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Use a MDBList list or one of the default autobrr hosted lists.
|
|
</p>
|
|
</div>
|
|
|
|
<SelectFieldCreatable
|
|
name="url"
|
|
label="List URL"
|
|
help="MDBLists.com lists. Override with your own."
|
|
options={ListsMDBListOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
|
|
/>
|
|
|
|
<div className="space-y-1">
|
|
<fieldset>
|
|
<legend className="sr-only">Settings</legend>
|
|
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface DownloadClientSelectProps {
|
|
name: string;
|
|
clientType: string;
|
|
clients: DownloadClient[];
|
|
}
|
|
|
|
function DownloadClientSelectCustom({ name, clientType, clients }: DownloadClientSelectProps) {
|
|
return (
|
|
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
|
<div>
|
|
<label
|
|
htmlFor={name}
|
|
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
|
|
>
|
|
<div className="flex">
|
|
Select Client
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Field name={name} type="select">
|
|
{({
|
|
field,
|
|
meta,
|
|
form: { setFieldValue }
|
|
}: FieldProps) => (
|
|
<Listbox
|
|
value={field.value}
|
|
onChange={(value) => setFieldValue(field?.name, value)}
|
|
>
|
|
{({ open }) => (
|
|
<>
|
|
{/*<Label className="block text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">*/}
|
|
{/* Client*/}
|
|
{/*</Label>*/}
|
|
<div className="relative">
|
|
<ListboxButton
|
|
className="block w-full shadow-xs sm:text-sm rounded-md border py-2 pl-3 pr-10 text-left focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100">
|
|
<span className="block truncate">
|
|
{field.value
|
|
? clients.find((c) => c.id === field.value)?.name
|
|
: "Choose a client"}
|
|
</span>
|
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
<ChevronUpDownIcon
|
|
className="h-5 w-5 text-gray-400 dark:text-gray-300"
|
|
aria-hidden="true"/>
|
|
</span>
|
|
</ListboxButton>
|
|
|
|
<Transition
|
|
show={open}
|
|
as={Fragment}
|
|
leave="transition ease-in duration-100"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
>
|
|
<ListboxOptions
|
|
static
|
|
className="absolute z-10 mt-1 w-full border border-gray-400 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-hidden sm:text-sm"
|
|
>
|
|
{clients
|
|
.filter((c) => c.type === clientType)
|
|
.map((client) => (
|
|
<ListboxOption
|
|
key={client.id}
|
|
className={({ focus }) => classNames(
|
|
focus
|
|
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-950"
|
|
: "text-gray-900 dark:text-gray-300",
|
|
"cursor-default select-none relative py-2 pl-3 pr-9"
|
|
)}
|
|
value={client.id}
|
|
>
|
|
{({ selected, focus }) => (
|
|
<>
|
|
<span
|
|
className={classNames(
|
|
selected ? "font-semibold" : "font-normal",
|
|
"block truncate"
|
|
)}
|
|
>
|
|
{client.name}
|
|
</span>
|
|
|
|
{selected ? (
|
|
<span
|
|
className={classNames(
|
|
focus ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-blue-500",
|
|
"absolute inset-y-0 right-0 flex items-center pr-4"
|
|
)}
|
|
>
|
|
<CheckIcon
|
|
className="h-5 w-5"
|
|
aria-hidden="true"/>
|
|
</span>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</ListboxOption>
|
|
))}
|
|
</ListboxOptions>
|
|
</Transition>
|
|
{meta.touched && meta.error && (
|
|
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Listbox>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface ListMultiSelectFieldProps {
|
|
name: string;
|
|
label: string;
|
|
help?: string;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
tooltip?: JSX.Element;
|
|
options: OptionBasicTyped<number | string>[];
|
|
}
|
|
|
|
export function ListArrTagsMultiSelectField({ name, label, help, tooltip, options }: ListMultiSelectFieldProps) {
|
|
return (
|
|
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
|
|
<div>
|
|
<label
|
|
htmlFor={name}
|
|
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
|
|
>
|
|
<div className="flex">
|
|
{tooltip ? (
|
|
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
|
|
) : label}
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Field name={name} type="select">
|
|
{({
|
|
field,
|
|
form: { setFieldValue }
|
|
}: FieldProps) => (
|
|
<>
|
|
<RMSC
|
|
{...field}
|
|
options={options}
|
|
// disabled={disabled}
|
|
labelledBy={name}
|
|
// isCreatable={creatable}
|
|
// onCreateOption={handleNewField}
|
|
value={field.value && field.value.map((item: MultiSelectOption) => ({
|
|
value: item.value ? item.value : item,
|
|
label: item.label ? item.label : item
|
|
}))}
|
|
onChange={(values: Array<MultiSelectOption>) => {
|
|
const am = values && values.map((i) => i.value);
|
|
|
|
setFieldValue(field.name, am);
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</Field>
|
|
{help && (
|
|
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|