enhancement(web): add react suspense and improve DX (#1089)

* add react suspense, fix broken stuff, clean up code, improve DX

enhancement: added react suspense + spinner to show loading (still can be added in certain places)
chore: cleaned up Header/NavBar code
chore: cleaned up DeleteModal code
chore: cleaned up other relevant code
enhancement: changed remove button style to be much more pleasant (see e.g. filter tabs)
fix: made active tab on filters page to be blue (as it should've been) when active
fix: fixed ghost delimiter which was only visible when DeleteModal was active in FormButtonGroup
chore: removed most of linter warnings/errors
fix: fixed incorrect/double modal transition in FilterExternalItem
fix: fixed incorrect z-height on Options popover in Settings/IRC (would've been visible when Add new was clicked)
enhancement: improved robustness of all Context classes to support seamless new-feature expansion (#866)
enhancement: improved expand logic (see #994 comments)

* reverted irc expand view to previous design

* forgot to propagate previous z-height fix

* jinxed it

* add license header to new files

---------

Co-authored-by: martylukyy <35452459+martylukyy@users.noreply.github.com>
Co-authored-by: Kyle Sanderson <kyle.leet@gmail.com>
This commit is contained in:
stacksmash76 2023-09-10 12:35:43 +02:00 committed by GitHub
parent cbf668e87c
commit 2fed48e0dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 845 additions and 737 deletions

View file

@ -21,7 +21,8 @@ const queryClient = new QueryClient({
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
retry: true,
useErrorBoundary: true
useErrorBoundary: true,
suspense: true,
},
mutations: {
onError: (error) => {

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { RingResizeSpinner } from "@components/Icons";
import { classNames } from "@utils";
const SIZE = {
small: "w-6 h-6",
medium: "w-8 h-8",
large: "w-12 h-12",
xlarge: "w-24 h-24"
} as const;
interface SectionLoaderProps {
$size: keyof typeof SIZE;
}
export const SectionLoader = ({ $size }: SectionLoaderProps) => {
if ($size === "xlarge") {
return (
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<RingResizeSpinner className={classNames(SIZE[$size], "mx-auto my-36 text-blue-500")} />
</div>
);
} else {
return (
<RingResizeSpinner className={classNames(SIZE[$size], "text-blue-500")} />
);
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import toast from "react-hot-toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Disclosure } from "@headlessui/react";
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast";
import { LeftNav } from "./LeftNav";
import { RightNav } from "./RightNav";
import { MobileNav } from "./MobileNav";
export const Header = () => {
const { data } = useQuery({
queryKey: ["updates"],
queryFn: () => APIClient.updates.getLatestRelease(),
retry: false,
refetchOnWindowFocus: false,
onError: err => console.log(err)
});
const logoutMutation = useMutation({
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
toast.custom((t) => (
<Toast type="success" body="You have been logged out. Goodbye!" t={t} />
));
}
});
return (
<Disclosure
as="nav"
className="bg-gradient-to-b from-gray-100 dark:from-[#141414]"
>
{({ open }) => (
<>
<div className="max-w-screen-xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-gray-300 dark:border-gray-700">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<LeftNav />
<RightNav logoutMutation={logoutMutation.mutate} />
<div className="-mr-2 flex sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="bg-gray-200 dark:bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-600 dark:text-gray-400 hover:text-white hover:bg-gray-700">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon
className="block h-6 w-6"
aria-hidden="true"
/>
) : (
<Bars3Icon
className="block h-6 w-6"
aria-hidden="true"
/>
)}
</Disclosure.Button>
</div>
</div>
</div>
{data && data.html_url && (
<a href={data.html_url} target="_blank" rel="noopener noreferrer">
<div className="flex mt-4 py-2 bg-blue-500 rounded justify-center">
<MegaphoneIcon className="h-6 w-6 text-blue-100" />
<span className="text-blue-100 font-medium mx-3">New update available!</span>
<span className="inline-flex items-center rounded-md bg-blue-100 px-2.5 py-0.5 text-sm font-medium text-blue-800">{data?.name}</span>
</div>
</a>
)}
</div>
<MobileNav logoutMutation={logoutMutation.mutate} />
</>
)}
</Disclosure>
)
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link, NavLink } from "react-router-dom";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
import { ReactComponent as Logo } from "@app/logo.svg";
import { NAV_ROUTES } from "./_shared";
export const LeftNav = () => (
<div className="flex items-center">
<div className="flex-shrink-0 flex items-center">
<Link to="/">
<Logo className="h-10" />
</Link>
</div>
<div className="sm:ml-3 hidden sm:block">
<div className="flex items-baseline space-x-4">
{NAV_ROUTES.map((item, itemIdx) => (
<NavLink
key={item.name + itemIdx}
to={item.path}
className={({ isActive }) =>
classNames(
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200",
isActive
? "text-black dark:text-gray-50 font-bold"
: "text-gray-600 dark:text-gray-500"
)
}
end={item.path === "/"}
>
{item.name}
</NavLink>
))}
<a
rel="noopener noreferrer"
target="_blank"
href="https://autobrr.com"
className={classNames(
"text-gray-600 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200 flex items-center justify-center"
)}
>
Docs
<ArrowTopRightOnSquareIcon
className="inline ml-1 h-5 w-5"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
);

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { NavLink } from "react-router-dom";
import { Disclosure } from "@headlessui/react";
import { classNames } from "@utils";
import { NAV_ROUTES } from "./_shared";
import type { RightNavProps } from "./_shared";
export const MobileNav = (props: RightNavProps) => (
<Disclosure.Panel className="border-b border-gray-300 dark:border-gray-700 md:hidden">
<div className="px-2 py-3 space-y-1 sm:px-3">
{NAV_ROUTES.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
classNames(
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
: "font-medium"
)
}
end={item.path === "/"}
>
{item.name}
</NavLink>
))}
<button
onClick={(e) => {
e.preventDefault();
props.logoutMutation();
}}
className="w-full shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base font-medium text-left"
>
Logout
</button>
</div>
</Disclosure.Panel>
);

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { UserIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
import { classNames } from "@utils";
import { AuthContext } from "@utils/Context";
import { RightNavProps } from "./_shared";
export const RightNav = (props: RightNavProps) => {
const authContext = AuthContext.useValue();
return (
<div className="hidden sm:block">
<div className="ml-4 flex items-center sm:ml-6">
<Menu as="div" className="ml-3 relative">
{({ open }) => (
<>
<Menu.Button
className={classNames(
open ? "bg-gray-200 dark:bg-gray-800" : "",
"text-gray-600 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"max-w-xs rounded-full flex items-center text-sm px-3 py-2",
"transition-colors duration-200"
)}
>
<span className="hidden text-sm font-medium sm:block">
<span className="sr-only">
Open user menu for{" "}
</span>
{authContext.username}
</span>
<UserIcon
className="inline ml-1 h-5 w-5"
aria-hidden="true"
/>
</Menu.Button>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-48 z-10 rounded-md shadow-lg py-1 bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<Menu.Item>
{({ active }) => (
<Link
to="/settings"
className={classNames(
active
? "bg-gray-100 dark:bg-gray-600"
: "",
"block px-4 py-2 text-sm text-gray-900 dark:text-gray-200"
)}
>
Settings
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={(e) => {
e.preventDefault();
props.logoutMutation();
}}
className={classNames(
active
? "bg-gray-100 dark:bg-gray-600"
: "",
"block w-full px-4 py-2 text-sm text-gray-900 dark:text-gray-200 text-left"
)}
>
Log out
</button>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
interface NavItem {
name: string;
path: string;
}
export interface RightNavProps {
logoutMutation: () => void;
}
export const NAV_ROUTES: Array<NavItem> = [
{ name: "Dashboard", path: "/" },
{ name: "Filters", path: "/filters" },
{ name: "Releases", path: "/releases" },
{ name: "Settings", path: "/settings" },
{ name: "Logs", path: "/logs" }
];

View file

@ -0,0 +1,6 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
export { Header } from "./Header";

View file

@ -7,24 +7,85 @@ import { FC, Fragment, MutableRefObject } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
interface DeleteModalProps {
isOpen: boolean;
buttonRef: MutableRefObject<HTMLElement | null> | undefined;
toggle: () => void;
deleteAction: () => void;
title: string;
text: string;
import { SectionLoader } from "@components/SectionLoader";
interface ModalUpperProps {
title: string;
text: string;
}
export const DeleteModal: FC<DeleteModalProps> = ({ isOpen, buttonRef, toggle, deleteAction, title, text }) => (
<Transition.Root show={isOpen} as={Fragment}>
interface ModalLowerProps {
isOpen: boolean;
isLoading: boolean;
toggle: () => void;
deleteAction: () => void;
}
interface DeleteModalProps extends ModalUpperProps, ModalLowerProps {
buttonRef: MutableRefObject<HTMLElement | null> | undefined;
}
const ModalUpper = ({ title, text }: ModalUpperProps) => (
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<ExclamationTriangleIcon className="h-16 w-16 text-red-500 dark:text-red-500" aria-hidden="true" />
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:pr-8 sm:text-left max-w-full">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900 dark:text-white break-words">
{title}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-300">
{text}
</p>
</div>
</div>
</div>
</div>
);
const ModalLower = ({ isOpen, isLoading, toggle, deleteAction }: ModalLowerProps) => (
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{isLoading ? (
<SectionLoader $size="small" />
) : (
<>
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={(e) => {
e.preventDefault();
if (isOpen) {
deleteAction();
toggle();
}
}}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={(e) => {
e.preventDefault();
toggle();
}}
>
Cancel
</button>
</>
)}
</div>
);
export const DeleteModal: FC<DeleteModalProps> = (props: DeleteModalProps) => (
<Transition.Root show={props.isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={buttonRef}
open={isOpen}
onClose={toggle}
initialFocus={props.buttonRef}
open={props.isOpen}
onClose={props.toggle}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
@ -52,43 +113,8 @@ export const DeleteModal: FC<DeleteModalProps> = ({ isOpen, buttonRef, toggle, d
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<ExclamationTriangleIcon className="h-16 w-16 text-red-500 dark:text-red-500" aria-hidden="true" />
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:pr-8 sm:text-left max-w-full">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900 dark:text-white break-words">
{title}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-300">
{text}
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={() => {
if (isOpen) {
deleteAction();
toggle();
}
}}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={toggle}
// ref={buttonRef}
>
Cancel
</button>
</div>
<ModalUpper {...props} />
<ModalLower {...props} />
</div>
</Transition.Child>
</div>

View file

@ -62,6 +62,7 @@ function SlideOver<DataType>({
{deleteAction && (
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={isTesting || false}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}

View file

@ -3,25 +3,38 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Suspense } from "react";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { baseUrl } from "@utils";
import { Header } from "@components/header";
import { SectionLoader } from "@components/SectionLoader";
import { NotFound } from "@components/alerts/NotFound";
import { Base } from "@screens/Base";
import { Dashboard } from "@screens/Dashboard";
import { Logs } from "@screens/Logs";
import { Filters, FilterDetails } from "@screens/filters";
import { Releases } from "@screens/Releases";
import { Settings } from "@screens/Settings";
import * as SettingsSubPage from "@screens/settings/index";
import { Dashboard } from "@screens/Dashboard";
import { Login, Onboarding } from "@screens/auth";
import { Filters, FilterDetails } from "@screens/filters";
import * as SettingsSubPage from "@screens/settings/index";
const BaseLayout = () => (
<div className="min-h-screen">
<Header />
<Suspense fallback={<SectionLoader $size="xlarge" />}>
<Outlet />
</Suspense>
</div>
);
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
<BrowserRouter basename={baseUrl()}>
{isLoggedIn ? (
<Routes>
<Route path="*" element={<NotFound />} />
<Route element={<Base />}>
<Route element={<BaseLayout />}>
<Route index element={<Dashboard />} />
<Route path="logs" element={<Logs />} />
<Route path="releases" element={<Releases />} />

View file

@ -833,6 +833,7 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
>
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}

View file

@ -1,253 +0,0 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment } from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { Disclosure, Menu, Transition } from "@headlessui/react";
import { ArrowTopRightOnSquareIcon, UserIcon } from "@heroicons/react/24/solid";
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { AuthContext } from "@utils/Context";
import { ReactComponent as Logo } from "@app/logo.svg";
import { APIClient } from "@api/APIClient";
import Toast from "@components/notifications/Toast";
import { classNames } from "@utils";
interface NavItem {
name: string;
path: string;
}
const nav: Array<NavItem> = [
{ name: "Dashboard", path: "/" },
{ name: "Filters", path: "/filters" },
{ name: "Releases", path: "/releases" },
{ name: "Settings", path: "/settings" },
{ name: "Logs", path: "/logs" }
];
export const Base = () => {
const authContext = AuthContext.useValue();
const { data } = useQuery({
queryKey: ["updates"],
queryFn: () => APIClient.updates.getLatestRelease(),
retry: false,
refetchOnWindowFocus: false,
onError: err => console.log(err)
});
const logoutMutation = useMutation( {
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
toast.custom((t) => (
<Toast type="success" body="You have been logged out. Goodbye!" t={t} />
));
}
});
const logoutAction = () => {
logoutMutation.mutate();
};
return (
<div className="min-h-screen">
<Disclosure
as="nav"
className="bg-gradient-to-b from-gray-100 dark:from-[#141414]"
>
{({ open }) => (
<>
<div className="max-w-screen-xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-gray-300 dark:border-gray-700">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<div className="flex items-center">
<div className="flex-shrink-0 flex items-center">
<Link to="/">
<Logo className="h-10" />
</Link>
</div>
<div className="sm:ml-3 hidden sm:block">
<div className="flex items-baseline space-x-4">
{nav.map((item, itemIdx) => (
<NavLink
key={item.name + itemIdx}
to={item.path}
className={({ isActive }) =>
classNames(
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200",
isActive
? "text-black dark:text-gray-50 font-bold"
: "text-gray-600 dark:text-gray-500"
)
}
end={item.path === "/"}
>
{item.name}
</NavLink>
))}
<a
rel="noopener noreferrer"
target="_blank"
href="https://autobrr.com"
className={classNames(
"text-gray-600 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200 flex items-center justify-center"
)}
>
Docs
<ArrowTopRightOnSquareIcon
className="inline ml-1 h-5 w-5"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
<div className="hidden sm:block">
<div className="ml-4 flex items-center sm:ml-6">
<Menu as="div" className="ml-3 relative">
{({ open }) => (
<>
<Menu.Button
className={classNames(
open ? "bg-gray-200 dark:bg-gray-800" : "",
"text-gray-600 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"max-w-xs rounded-full flex items-center text-sm px-3 py-2",
"transition-colors duration-200"
)}
>
<span className="hidden text-sm font-medium sm:block">
<span className="sr-only">
Open user menu for{" "}
</span>
{authContext.username}
</span>
<UserIcon
className="inline ml-1 h-5 w-5"
aria-hidden="true"
/>
</Menu.Button>
<Transition
show={open}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-48 z-10 rounded-md shadow-lg py-1 bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<Menu.Item>
{({ active }) => (
<Link
to="/settings"
className={classNames(
active
? "bg-gray-100 dark:bg-gray-600"
: "",
"block px-4 py-2 text-sm text-gray-900 dark:text-gray-200"
)}
>
Settings
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={logoutAction}
className={classNames(
active
? "bg-gray-100 dark:bg-gray-600"
: "",
"block w-full px-4 py-2 text-sm text-gray-900 dark:text-gray-200 text-left"
)}
>
Log out
</button>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</>
)}
</Menu>
</div>
</div>
<div className="-mr-2 flex sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="bg-gray-200 dark:bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-600 dark:text-gray-400 hover:text-white hover:bg-gray-700">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon
className="block h-6 w-6"
aria-hidden="true"
/>
) : (
<Bars3Icon
className="block h-6 w-6"
aria-hidden="true"
/>
)}
</Disclosure.Button>
</div>
</div>
</div>
{data && data.html_url && (
<a href={data.html_url} target="_blank" rel="noopener noreferrer">
<div className="flex mt-4 py-2 bg-blue-500 rounded justify-center">
<MegaphoneIcon className="h-6 w-6 text-blue-100"/>
<span className="text-blue-100 font-medium mx-3">New update available!</span>
<span className="inline-flex items-center rounded-md bg-blue-100 px-2.5 py-0.5 text-sm font-medium text-blue-800">{data?.name}</span>
</div>
</a>
)}
</div>
<Disclosure.Panel className="border-b border-gray-300 dark:border-gray-700 md:hidden">
<div className="px-2 py-3 space-y-1 sm:px-3">
{nav.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
classNames(
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
: "font-medium"
)
}
end={item.path === "/"}
>
{item.name}
</NavLink>
))}
<button
onClick={logoutAction}
className="w-full shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base font-medium text-left"
>
Logout
</button>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
<Outlet />
</div>
);
};

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
BellIcon,
@ -16,6 +17,7 @@ import {
} from "@heroicons/react/24/outline";
import { classNames } from "@utils";
import { SectionLoader } from "@components/SectionLoader";
interface NavTabType {
name: string;
@ -96,7 +98,15 @@ export function Settings() {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<SidebarNav subNavigation={subNavigation}/>
<Outlet />
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<SectionLoader $size="large" />
</div>
}
>
<Outlet />
</Suspense>
</div>
</div>
</div>

View file

@ -102,10 +102,10 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
{values.actions.length > 0 ?
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{values.actions.map((action: Action, index: number) => (
<FilterActionsItem action={action} clients={data ?? []} idx={index} initialEdit={values.actions.length === 1} remove={remove} key={index}/>
<FilterActionsItem action={action} clients={data ?? []} idx={index} initialEdit={values.actions.length === 1} remove={remove} key={index} />
))}
</ul>
: <EmptyListState text="No actions yet!"/>
: <EmptyListState text="No actions yet!" />
}
</div>
</Fragment>
@ -122,7 +122,7 @@ interface TypeFormProps {
}
const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
const { setFieldValue } = useFormikContext();
const { setFieldValue } = useFormikContext();
const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
const fieldName = `actions.${idx}.client_id`;
@ -544,9 +544,9 @@ const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
clients={clients}
/>
<NumberField
name={`actions.${idx}.external_download_client_id`}
label="Override download client id for arr"
tooltip={<p>Override Download client Id from the one set in Clients. Useful if you have multiple clients inside the arr.</p>}
name={`actions.${idx}.external_download_client_id`}
label="Override download client id for arr"
tooltip={<p>Override Download client Id from the one set in Clients. Useful if you have multiple clients inside the arr.</p>}
/>
</div>
);
@ -648,7 +648,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">
<p className="ml-4 font-medium text-blue-600 dark:text-gray-100 truncate">
<p className="ml-4 font-medium text-dark-600 dark:text-gray-100 truncate">
{action.name}
</p>
</div>
@ -683,6 +683,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
>
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={removeMutation.isLoading}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={() => removeAction(action.id)}
@ -706,13 +707,13 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
<TextField name={`actions.${idx}.name`} label="Name" columns={6} />
</div>
<TypeForm action={action} clients={clients} idx={idx}/>
<TypeForm action={action} clients={clients} idx={idx} />
<div className="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-red-500 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove

View file

@ -3,53 +3,54 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import {ReactNode, useEffect, useRef} from "react";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {NavLink, Route, Routes, useLocation, useNavigate, useParams} from "react-router-dom";
import {toast} from "react-hot-toast";
import {Form, Formik, FormikValues, useFormikContext} from "formik";
import {z} from "zod";
import {toFormikValidationSchema} from "zod-formik-adapter";
import {ChevronDownIcon, ChevronRightIcon} from "@heroicons/react/24/solid";
import { ReactNode, Suspense, useEffect, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "react-hot-toast";
import { Form, Formik, FormikValues, useFormikContext } from "formik";
import { z } from "zod";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
import {
CODECS_OPTIONS,
CONTAINER_OPTIONS,
downloadsPerUnitOptions,
FORMATS_OPTIONS,
HDR_OPTIONS,
LANGUAGE_OPTIONS,
ORIGIN_OPTIONS,
OTHER_OPTIONS,
QUALITY_MUSIC_OPTIONS,
RELEASE_TYPE_MUSIC_OPTIONS,
RESOLUTION_OPTIONS,
SOURCES_MUSIC_OPTIONS,
SOURCES_OPTIONS,
tagsMatchLogicOptions
CODECS_OPTIONS,
CONTAINER_OPTIONS,
downloadsPerUnitOptions,
FORMATS_OPTIONS,
HDR_OPTIONS,
LANGUAGE_OPTIONS,
ORIGIN_OPTIONS,
OTHER_OPTIONS,
QUALITY_MUSIC_OPTIONS,
RELEASE_TYPE_MUSIC_OPTIONS,
RESOLUTION_OPTIONS,
SOURCES_MUSIC_OPTIONS,
SOURCES_OPTIONS,
tagsMatchLogicOptions
} from "@app/domain/constants";
import {APIClient} from "@api/APIClient";
import {useToggle} from "@hooks/hooks";
import {classNames} from "@utils";
import { APIClient } from "@api/APIClient";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import {
CheckboxField,
IndexerMultiSelect,
MultiSelect,
NumberField,
RegexField,
Select,
SwitchGroup,
TextField
CheckboxField,
IndexerMultiSelect,
MultiSelect,
NumberField,
RegexField,
Select,
SwitchGroup,
TextField
} from "@components/inputs";
import DEBUG from "@components/debug";
import Toast from "@components/notifications/Toast";
import {DeleteModal} from "@components/modals";
import {TitleSubtitle} from "@components/headings";
import {RegexTextAreaField, TextAreaAutoResize} from "@components/inputs/input";
import {FilterActions} from "./Action";
import {filterKeys} from "./List";
import {External} from "@screens/filters/External";
import { DeleteModal } from "@components/modals";
import { TitleSubtitle } from "@components/headings";
import { RegexTextAreaField, TextAreaAutoResize } from "@components/inputs/input";
import { FilterActions } from "./Action";
import { filterKeys } from "./List";
import { External } from "@screens/filters/External";
import { SectionLoader } from "@components/SectionLoader";
interface tabType {
name: string;
@ -80,8 +81,8 @@ function TabNavLink({ item }: NavLinkProps) {
to={item.href}
end
className={({ isActive }) => classNames(
"text-gray-500 hover:text-blue-600 dark:hover:text-white hover:border-blue-600 dark:hover:border-blue-500 whitespace-nowrap py-4 px-1 font-medium text-sm",
isActive ? "border-b-2 border-blue-600 dark:border-blue-500 text-blue-600 dark:text-white" : ""
"hover:text-blue-600 dark:hover:text-white hover:border-blue-600 dark:hover:border-blue-500 whitespace-nowrap py-4 px-1 font-medium text-sm",
isActive ? "border-b-2 border-blue-600 dark:border-blue-500 text-blue-600 dark:text-white" : "text-gray-500"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
>
@ -95,17 +96,19 @@ interface FormButtonsGroupProps {
deleteAction: () => void;
reset: () => void;
dirty?: boolean;
isLoading: boolean;
}
const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps) => {
const FormButtonsGroup = ({ values, deleteAction, reset, isLoading }: FormButtonsGroupProps) => {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const cancelModalButtonRef = useRef(null);
return (
<div className="pt-6 divide-y divide-gray-200 dark:divide-gray-700">
<div className="mt-12 border-t border-gray-200 dark:border-gray-700">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}
@ -116,7 +119,7 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 rounded-md text-red-700 dark:text-red-500 light:bg-red-100 light:hover:bg-red-200 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove
@ -131,7 +134,7 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
e.preventDefault();
reset();
toast.custom((t) => <Toast type="success" body="Reset all filter values." t={t}/>);
toast.custom((t) => <Toast type="success" body="Reset all filter values." t={t} />);
}}
>
Reset form values
@ -154,19 +157,19 @@ const FormErrorNotification = () => {
useEffect(() => {
if (!isValid && !isValidating && isSubmitting) {
console.log("validation errors: ", errors);
toast.custom((t) => <Toast type="error" body={`Validation error. Check fields: ${Object.keys(errors)}`} t={t}/>);
toast.custom((t) => <Toast type="error" body={`Validation error. Check fields: ${Object.keys(errors)}`} t={t} />);
}
}, [isSubmitting, isValid, isValidating]);
return null;
};
const allowedClientType = ["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION","PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD"];
const allowedClientType = ["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION", "PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD"];
const actionSchema = z.object({
enabled: z.boolean(),
name: z.string(),
type: z.enum(["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION","PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD", "TEST", "EXEC", "WATCH_FOLDER", "WEBHOOK"]),
type: z.enum(["QBITTORRENT", "DELUGE_V1", "DELUGE_V2", "RTORRENT", "TRANSMISSION", "PORLA", "RADARR", "SONARR", "LIDARR", "WHISPARR", "READARR", "SABNZBD", "TEST", "EXEC", "WATCH_FOLDER", "WEBHOOK"]),
client_id: z.number().optional(),
exec_cmd: z.string().optional(),
exec_args: z.string().optional(),
@ -202,18 +205,18 @@ const actionSchema = z.object({
});
const externalFilterSchema = z.object({
enabled: z.boolean(),
index: z.number(),
name: z.string(),
type: z.enum(["EXEC", "WEBHOOK"]),
exec_cmd: z.string().optional(),
exec_args: z.string().optional(),
exec_expect_status: z.number().optional(),
webhook_host: z.string().optional(),
webhook_type: z.string().optional(),
webhook_method: z.string().optional(),
webhook_data: z.string().optional(),
webhook_expect_status: z.number().optional(),
enabled: z.boolean(),
index: z.number(),
name: z.string(),
type: z.enum(["EXEC", "WEBHOOK"]),
exec_cmd: z.string().optional(),
exec_args: z.string().optional(),
exec_expect_status: z.number().optional(),
webhook_host: z.string().optional(),
webhook_type: z.string().optional(),
webhook_method: z.string().optional(),
webhook_data: z.string().optional(),
webhook_expect_status: z.number().optional(),
});
const indexerSchema = z.object({
@ -262,7 +265,7 @@ export function FilterDetails() {
});
toast.custom((t) => (
<Toast type="success" body={`${newFilter.name} was updated successfully`} t={t}/>
<Toast type="success" body={`${newFilter.name} was updated successfully`} t={t} />
));
}
});
@ -278,16 +281,11 @@ export function FilterDetails() {
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
));
// redirect
navigate("/filters");
}
});
if (isLoading) {
return null;
}
if (!filter) {
return null;
}
@ -406,15 +404,23 @@ export function FilterDetails() {
{({ values, dirty, resetForm }) => (
<Form>
<FormErrorNotification />
<Routes>
<Route index element={<General />} />
<Route path="movies-tv" element={<MoviesTv />} />
<Route path="music" element={<Music values={values} />} />
<Route path="advanced" element={<Advanced values={values} />} />
<Route path="external" element={<External />} />
<Route path="actions" element={<FilterActions filter={filter} values={values} />} />
</Routes>
<FormButtonsGroup values={values} deleteAction={deleteAction} dirty={dirty} reset={resetForm} />
<Suspense fallback={<SectionLoader $size="large" />}>
<Routes>
<Route index element={<General />} />
<Route path="movies-tv" element={<MoviesTv />} />
<Route path="music" element={<Music values={values} />} />
<Route path="advanced" element={<Advanced values={values} />} />
<Route path="external" element={<External />} />
<Route path="actions" element={<FilterActions filter={filter} values={values} />} />
</Routes>
</Suspense>
<FormButtonsGroup
values={values}
deleteAction={deleteAction}
dirty={dirty}
reset={resetForm}
isLoading={isLoading}
/>
<DEBUG values={values} />
</Form>
)}
@ -426,7 +432,7 @@ export function FilterDetails() {
);
}
export function General(){
export function General() {
const { isLoading, data: indexers } = useQuery({
queryKey: ["filters", "indexer_list"],
queryFn: APIClient.indexers.getOptions,
@ -455,11 +461,11 @@ export function General(){
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="min_size" label="Min size" columns={6} placeholder="eg. 100MiB, 80GB" tooltip={<div><p>Supports units such as MB, MiB, GB, etc.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<TextField name="max_size" label="Max size" columns={6} placeholder="eg. 100MiB, 80GB" tooltip={<div><p>Supports units such as MB, MiB, GB, etc.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<NumberField name="delay" label="Delay" placeholder="Number of seconds to delay actions" tooltip={<div><p>Number of seconds to wait before running actions.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<TextField name="max_size" label="Max size" columns={6} placeholder="eg. 100MiB, 80GB" tooltip={<div><p>Supports units such as MB, MiB, GB, etc.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<NumberField name="delay" label="Delay" placeholder="Number of seconds to delay actions" tooltip={<div><p>Number of seconds to wait before running actions.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<NumberField name="priority" label="Priority" placeholder="Higher number = higher prio" tooltip={<div><p>Filters are checked in order of priority. Higher number = higher priority.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<NumberField name="max_downloads" label="Max downloads" placeholder="Takes any number (0 is infinite)" tooltip={<div><p>Number of max downloads as specified by the respective unit.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<Select name="max_downloads_unit" label="Max downloads per" options={downloadsPerUnitOptions} optionDefaultText="Select unit" tooltip={<div><p>The unit of time for counting the maximum downloads per filter.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
<Select name="max_downloads_unit" label="Max downloads per" options={downloadsPerUnitOptions} optionDefaultText="Select unit" tooltip={<div><p>The unit of time for counting the maximum downloads per filter.</p><a href='https://autobrr.com/filters#rules' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#rules</a></div>} />
</div>
</div>
@ -474,19 +480,19 @@ export function MoviesTv() {
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextAreaAutoResize name="shows" label="Movies / Shows" columns={8} placeholder="eg. Movie,Show 1,Show?2" tooltip={<div><p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p><a href='https://autobrr.com/filters#tvmovies' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#tvmovies</a></div>} />
<TextField name="years" label="Years" columns={4} placeholder="eg. 2018,2019-2021" tooltip={<div><p>This field takes a range of years and/or comma separated single years.</p><a href='https://autobrr.com/filters#tvmovies' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#tvmovies</a></div>} />
<TextAreaAutoResize name="shows" label="Movies / Shows" columns={8} placeholder="eg. Movie,Show 1,Show?2" tooltip={<div><p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p><a href='https://autobrr.com/filters#tvmovies' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#tvmovies</a></div>} />
<TextField name="years" label="Years" columns={4} placeholder="eg. 2018,2019-2021" tooltip={<div><p>This field takes a range of years and/or comma separated single years.</p><a href='https://autobrr.com/filters#tvmovies' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#tvmovies</a></div>} />
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Seasons and Episodes" subtitle="Set season and episode match constraints." />
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1,3,2-6" tooltip={<div><p>See docs for information about how to <b>only</b> grab season packs:</p><a href='https://autobrr.com/filters/examples#only-season-packs' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/examples#only-season-packs</a></div>} />
<TextField name="episodes" label="Episodes" columns={4} placeholder="eg. 2,4,10-20" tooltip={<div><p>See docs for information about how to <b>only</b> grab episodes:</p><a href='https://autobrr.com/filters/examples/#skip-season-packs' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/examples/#skip-season-packs</a></div>} />
<TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1,3,2-6" tooltip={<div><p>See docs for information about how to <b>only</b> grab season packs:</p><a href='https://autobrr.com/filters/examples#only-season-packs' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/examples#only-season-packs</a></div>} />
<TextField name="episodes" label="Episodes" columns={4} placeholder="eg. 2,4,10-20" tooltip={<div><p>See docs for information about how to <b>only</b> grab episodes:</p><a href='https://autobrr.com/filters/examples/#skip-season-packs' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/examples/#skip-season-packs</a></div>} />
</div>
<div className="mt-6">
<CheckboxField name="smart_episode" label="Smart Episode" sublabel="Do not match episodes older than the last one matched."/> {/*Do not match older or already existing episodes.*/}
<CheckboxField name="smart_episode" label="Smart Episode" sublabel="Do not match episodes older than the last one matched." /> {/*Do not match older or already existing episodes.*/}
</div>
</div>
@ -494,23 +500,23 @@ export function MoviesTv() {
<TitleSubtitle title="Quality" subtitle="Set resolution, source, codec and related match constraints." />
<div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelect name="resolutions" options={RESOLUTION_OPTIONS} label="resolutions" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected resolutions.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="sources" options={SOURCES_OPTIONS} label="sources" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected sources.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="resolutions" options={RESOLUTION_OPTIONS} label="resolutions" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected resolutions.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="sources" options={SOURCES_OPTIONS} label="sources" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected sources.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelect name="codecs" options={CODECS_OPTIONS} label="codecs" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected codecs.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="containers" options={CONTAINER_OPTIONS} label="containers" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected containers.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="codecs" options={CODECS_OPTIONS} label="codecs" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected codecs.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="containers" options={CONTAINER_OPTIONS} label="containers" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected containers.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelect name="match_hdr" options={HDR_OPTIONS} label="Match HDR" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected HDR designations.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="except_hdr" options={HDR_OPTIONS} label="Except HDR" columns={6} creatable={true} tooltip={<div><p>Won't match releases which contain any of the selected HDR designations (takes priority over Match HDR).</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="match_hdr" options={HDR_OPTIONS} label="Match HDR" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected HDR designations.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="except_hdr" options={HDR_OPTIONS} label="Except HDR" columns={6} creatable={true} tooltip={<div><p>Won't match releases which contain any of the selected HDR designations (takes priority over Match HDR).</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelect name="match_other" options={OTHER_OPTIONS} label="Match Other" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected designations.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="except_other" options={OTHER_OPTIONS} label="Except Other" columns={6} creatable={true} tooltip={<div><p>Won't match releases which contain any of the selected Other designations (takes priority over Match Other).</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="match_other" options={OTHER_OPTIONS} label="Match Other" columns={6} creatable={true} tooltip={<div><p>Will match releases which contain any of the selected designations.</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
<MultiSelect name="except_other" options={OTHER_OPTIONS} label="Except Other" columns={6} creatable={true} tooltip={<div><p>Won't match releases which contain any of the selected Other designations (takes priority over Match Other).</p><a href='https://autobrr.com/filters#quality' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#quality</a></div>} />
</div>
</div>
</div>
@ -580,8 +586,8 @@ export function Advanced({ values }: AdvancedProps) {
<div className="grid col-span-12 gap-6">
<WarningAlert text="autobrr has extensive filtering built-in - only use this if nothing else works. If you need help please ask." />
<RegexTextAreaField name="match_releases" label="Match releases" useRegex={values.use_regex} columns={6} placeholder="eg. *some?movie*,*some?show*s01*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br/><br/><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
<RegexTextAreaField name="except_releases" label="Except releases" useRegex={values.use_regex} columns={6} placeholder="eg. *bad?movie*,*bad?show*s03*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br/><br/><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
<RegexTextAreaField name="match_releases" label="Match releases" useRegex={values.use_regex} columns={6} placeholder="eg. *some?movie*,*some?show*s01*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br /><br /><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
<RegexTextAreaField name="except_releases" label="Except releases" useRegex={values.use_regex} columns={6} placeholder="eg. *bad?movie*,*bad?show*s03*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br /><br /><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
{values.match_releases ? (
<WarningAlert
@ -617,9 +623,9 @@ export function Advanced({ values }: AdvancedProps) {
<TextAreaAutoResize name="except_categories" label="Except categories" columns={6} placeholder="eg. *category*" tooltip={<div><p>Comma separated list of categories to ignore (takes priority over Match releases).</p><a href='https://autobrr.com/filters/categories' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters/categories</a></div>} />
<TextAreaAutoResize name="tags" label="Match tags" columns={4} placeholder="eg. tag1,tag2" tooltip={<div><p>Comma separated list of tags to match.</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a></div>} />
<Select name="tags_match_logic" label="Tags logic" columns={2} options={tagsMatchLogicOptions} optionDefaultText="any" tooltip={<div><p>Logic used to match filter tags.</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a></div>} />
<Select name="tags_match_logic" label="Tags logic" columns={2} options={tagsMatchLogicOptions} optionDefaultText="any" tooltip={<div><p>Logic used to match filter tags.</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a></div>} />
<TextAreaAutoResize name="except_tags" label="Except tags" columns={4} placeholder="eg. tag1,tag2" tooltip={<div><p>Comma separated list of tags to ignore (takes priority over Match releases).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>hhttps://autobrr.com/filters#advanced</a></div>} />
<Select name="except_tags_match_logic" label="Except tags logic" columns={2} options={tagsMatchLogicOptions} optionDefaultText="any" tooltip={<div><p>Logic used to match except tags.</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a></div>} />
<Select name="except_tags_match_logic" label="Except tags logic" columns={2} options={tagsMatchLogicOptions} optionDefaultText="any" tooltip={<div><p>Logic used to match except tags.</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a></div>} />
</CollapsableSection>
<CollapsableSection defaultOpen={true} title="Uploaders" subtitle="Match or ignore uploaders.">
@ -652,8 +658,8 @@ export function Advanced({ values }: AdvancedProps) {
<CollapsableSection defaultOpen={true} title="Feeds" subtitle="These options are only for Feeds such as RSS, Torznab and Newznab">
{/*<div className="grid col-span-12 gap-6">*/}
<RegexTextAreaField name="match_description" label="Match description" useRegex={values.use_regex_description} columns={6} placeholder="eg. *some?movie*,*some?show*s01*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br/><br/><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
<RegexTextAreaField name="except_description" label="Except description" useRegex={values.use_regex_description} columns={6} placeholder="eg. *bad?movie*,*bad?show*s03*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br/><br/><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
<RegexTextAreaField name="match_description" label="Match description" useRegex={values.use_regex_description} columns={6} placeholder="eg. *some?movie*,*some?show*s01*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br /><br /><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
<RegexTextAreaField name="except_description" label="Except description" useRegex={values.use_regex_description} columns={6} placeholder="eg. *bad?movie*,*bad?show*s03*" tooltip={<div><p>This field has full regex support (Golang flavour).</p><a href='https://autobrr.com/filters#advanced' className='text-blue-400 visited:text-blue-400' target='_blank'>https://autobrr.com/filters#advanced</a><br /><br /><p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p></div>} />
{/*</div>*/}
<div className="col-span-6">
@ -702,10 +708,10 @@ function WarningAlert({ text, alert, colors }: WarningAlertProps) {
}
interface CollapsableSectionProps {
title: string;
subtitle: string;
children: ReactNode;
defaultOpen?: boolean;
title: string;
subtitle: string;
children: ReactNode;
defaultOpen?: boolean;
}
export function CollapsableSection({ title, subtitle, children, defaultOpen }: CollapsableSectionProps) {

View file

@ -6,11 +6,11 @@
import { Field, FieldArray, FieldArrayRenderProps, FieldProps, useFormikContext } from "formik";
import { NumberField, Select, TextField } from "@components/inputs";
import { TextArea } from "@components/inputs/input";
import { Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useRef } from "react";
import { EmptyListState } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
import { Switch as SwitchBasic } from "@headlessui/react";
import {
ExternalFilterTypeNameMap,
ExternalFilterTypeOptions,
@ -21,20 +21,20 @@ import { DeleteModal } from "@components/modals";
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";
export function External() {
const {values} = useFormikContext<Filter>();
const { values } = useFormikContext<Filter>();
const newItem: ExternalFilter = {
id: values.external.length + 1,
index: values.external.length,
name: `External ${values.external.length + 1}`,
enabled: false,
type: "EXEC",
}
type: "EXEC"
};
return (
<div className="mt-10">
<FieldArray name="external">
{({remove, push, move}: FieldArrayRenderProps) => (
{({ remove, push, move }: FieldArrayRenderProps) => (
<Fragment>
<div className="-ml-4 -mt-4 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
@ -58,10 +58,10 @@ export function External() {
{values.external.length > 0
? <ul className="divide-y divide-gray-200 dark:divide-gray-700">
{values.external.map((f, index: number) => (
<FilterExternalItem external={f} idx={index} key={index} remove={remove} move={move} initialEdit={true}/>
<FilterExternalItem external={f} idx={index} key={index} remove={remove} move={move} initialEdit={true} />
))}
</ul>
: <EmptyListState text="No external filters yet!"/>
: <EmptyListState text="No external filters yet!" />
}
</div>
</Fragment>
@ -80,7 +80,7 @@ interface FilterExternalItemProps {
}
function FilterExternalItem({ idx, external, initialEdit, remove, move }: FilterExternalItemProps) {
const {values, setFieldValue} = useFormikContext<Filter>();
const { values, setFieldValue } = useFormikContext<Filter>();
const cancelButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
@ -91,13 +91,13 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
};
const moveUp = () => {
move(idx, idx - 1)
setFieldValue(`external.${idx}.index`, idx - 1)
move(idx, idx - 1);
setFieldValue(`external.${idx}.index`, idx - 1);
};
const moveDown = () => {
move(idx, idx + 1)
setFieldValue(`external.${idx}.index`, idx + 1)
move(idx, idx + 1);
setFieldValue(`external.${idx}.index`, idx + 1);
};
return (
@ -130,9 +130,9 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
<Field name={`external.${idx}.enabled`} type="checkbox">
{({
field,
form: {setFieldValue}
}: FieldProps) => (
field,
form: { setFieldValue }
}: FieldProps) => (
<SwitchBasic
{...field}
type="button"
@ -162,7 +162,7 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">
<p className="ml-4 font-medium text-blue-600 dark:text-gray-100 truncate">
<p className="ml-4 font-medium text-dark-600 dark:text-gray-100 truncate">
{external.name}
</p>
</div>
@ -183,28 +183,17 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
</div>
{edit && (
<div className="px-4 py-4 flex items-center sm:px-6 border dark:border-gray-600">
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-y-auto"
initialFocus={cancelButtonRef}
open={deleteModalIsOpen}
onClose={toggleDeleteModal}
>
<DeleteModal
isOpen={deleteModalIsOpen}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={removeAction}
title="Remove external filter"
text="Are you sure you want to remove this external filter? This action cannot be undone."
/>
</Dialog>
</Transition.Root>
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={false}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={removeAction}
title="Remove external filter"
text="Are you sure you want to remove this external filter? This action cannot be undone."
/>
<div className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<Select
name={`external.${idx}.type`}
@ -214,16 +203,16 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
tooltip={<div><p>Select the type for this external filter.</p></div>}
/>
<TextField name={`external.${idx}.name`} label="Name" columns={6}/>
<TextField name={`external.${idx}.name`} label="Name" columns={6} />
</div>
<TypeForm external={external} idx={idx}/>
<TypeForm external={external} idx={idx} />
<div className="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-red-500 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove
@ -232,7 +221,9 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
<div>
<button
type="button"
className="light:bg-white light:border light:border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 dark:text-gray-500 light:hover:bg-gray-50 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className={
"bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
}
onClick={toggleEdit}
>
Close
@ -240,7 +231,6 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
</div>
</div>
</div>
</div>
</div>
)}
@ -254,79 +244,87 @@ interface TypeFormProps {
idx: number;
}
const TypeForm = ({external, idx}: TypeFormProps) => {
const TypeForm = ({ external, idx }: TypeFormProps) => {
switch (external.type) {
case "EXEC":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`external.${idx}.exec_cmd`}
label="Command"
columns={6}
placeholder="Absolute path to executable eg. /bin/test"
tooltip={<div><p>For custom commands you should specify the full path to the binary/program
you want to run. And you can include your own static variables:</p><a
href='https://autobrr.com/filters/actions#custom-commands--exec'
className='text-blue-400 visited:text-blue-400'
target='_blank'>https://autobrr.com/filters/actions#custom-commands--exec</a></div>}
/>
<TextField
name={`external.${idx}.exec_args`}
label="Arguments"
columns={6}
placeholder={`Arguments eg. --test "{{ .TorrentName }}"`}
/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`external.${idx}.exec_expect_status`}
label="Expected exit status"
placeholder="0"
/>
</div>
</div>
);
case "WEBHOOK":
return (
case "EXEC":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`external.${idx}.webhook_host`}
label="Host"
name={`external.${idx}.exec_cmd`}
label="Command"
columns={6}
placeholder="Host eg. http://localhost/webhook"
tooltip={<p>URL or IP to api. Pass params and set api tokens etc.</p>}
/>
<Select
name={`external.${idx}.webhook_method`}
label="HTTP method"
optionDefaultText="Select http method"
options={ExternalFilterWebhookMethodOptions}
tooltip={<div><p>Select the HTTP method for this webhook. Defaults to POST</p></div>}
placeholder="Absolute path to executable eg. /bin/test"
tooltip={
<div>
<p>
For custom commands you should specify the full path to the binary/program
you want to run. And you can include your own static variables:
</p>
<a
href="https://autobrr.com/filters/actions#custom-commands--exec"
className="text-blue-400 visited:text-blue-400"
target="_blank">https://autobrr.com/filters/actions#custom-commands--exec
</a>
</div>
}
/>
<TextField
name={`external.${idx}.webhook_headers`}
label="Headers"
name={`external.${idx}.exec_args`}
label="Arguments"
columns={6}
placeholder="HEADER=custom1,HEADER2=custom2"
/>
<TextArea
name={`external.${idx}.webhook_data`}
label="Data (json)"
columns={6}
rows={5}
placeholder={"Request data: { \"key\": \"value\" }"}
/>
<NumberField
name={`external.${idx}.webhook_expect_status`}
label="Expected http status"
placeholder="200"
placeholder={"Arguments eg. --test \"{{ .TorrentName }}\""}
/>
</div>
);
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`external.${idx}.exec_expect_status`}
label="Expected exit status"
placeholder="0"
/>
</div>
</div>
);
case "WEBHOOK":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`external.${idx}.webhook_host`}
label="Host"
columns={6}
placeholder="Host eg. http://localhost/webhook"
tooltip={<p>URL or IP to api. Pass params and set api tokens etc.</p>}
/>
<Select
name={`external.${idx}.webhook_method`}
label="HTTP method"
optionDefaultText="Select http method"
options={ExternalFilterWebhookMethodOptions}
tooltip={<div><p>Select the HTTP method for this webhook. Defaults to POST</p></div>}
/>
<TextField
name={`external.${idx}.webhook_headers`}
label="Headers"
columns={6}
placeholder="HEADER=custom1,HEADER2=custom2"
/>
<TextArea
name={`external.${idx}.webhook_data`}
label="Data (json)"
columns={6}
rows={5}
placeholder={"Request data: { \"key\": \"value\" }"}
/>
default:
return null;
<NumberField
name={`external.${idx}.webhook_expect_status`}
label="Expected http status"
placeholder="200"
/>
</div>
);
default:
return null;
}
};

View file

@ -484,6 +484,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
<Menu as="div">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={() => {

View file

@ -119,6 +119,7 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
<li className="text-gray-500 dark:text-gray-400">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={() => {

View file

@ -23,7 +23,7 @@ import { DeleteModal } from "@components/modals";
import { FeedUpdateForm } from "@forms/settings/FeedForms";
import { EmptySimple } from "@components/emptystates";
import { ImplementationBadges } from "./Indexer";
import {ArrowPathIcon} from "@heroicons/react/24/solid";
import { ArrowPathIcon } from "@heroicons/react/24/solid";
export const feedKeys = {
all: ["feeds"] as const,
@ -64,7 +64,8 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
return sortableItems;
}, [items, sortConfig]);
const requestSort = (key: keyof ListItemProps["feed"] | "enabled") => { let direction: "ascending" | "descending" = "ascending";
const requestSort = (key: keyof ListItemProps["feed"] | "enabled") => {
let direction: "ascending" | "descending" = "ascending";
if (
sortConfig &&
sortConfig.key === key &&
@ -133,13 +134,13 @@ function FeedSettings() {
Last run <span className="sort-indicator">{sortedFeeds.getSortIndicator("last_run")}</span>
</div>
<div
className="hidden md:flex col-span-2 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("next_run")}>
className="hidden md:flex col-span-2 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("next_run")}>
Next run <span className="sort-indicator">{sortedFeeds.getSortIndicator("next_run")}</span>
</div>
</li>
{sortedFeeds.items.map((feed) => (
<ListItem key={feed.id} feed={feed}/>
<ListItem key={feed.id} feed={feed} />
))}
</ol>
</section>
@ -150,7 +151,7 @@ function FeedSettings() {
}
interface ListItemProps {
feed: Feed;
feed: Feed;
}
function ListItem({ feed }: ListItemProps) {
@ -165,7 +166,7 @@ function ListItem({ feed }: ListItemProps) {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`${feed.name} was ${!enabled ? "disabled" : "enabled"} successfully.`} t={t}/>);
toast.custom((t) => <Toast type="success" body={`${feed.name} was ${!enabled ? "disabled" : "enabled"} successfully.`} t={t} />);
}
});
@ -176,7 +177,7 @@ function ListItem({ feed }: ListItemProps) {
return (
<li key={feed.id} className="text-gray-500 dark:text-gray-400">
<FeedUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} feed={feed}/>
<FeedUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} feed={feed} />
<div className="grid grid-cols-12 items-center">
<div className="col-span-2 sm:col-span-1 px-6 flex items-center">
@ -230,9 +231,9 @@ function ListItem({ feed }: ListItemProps) {
}
interface FeedItemDropdownProps {
feed: Feed;
onToggle: (newState: boolean) => void;
toggleUpdate: () => void;
feed: Feed;
onToggle: (newState: boolean) => void;
toggleUpdate: () => void;
}
const FeedItemDropdown = ({
@ -254,14 +255,14 @@ const FeedItemDropdown = ({
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t}/>);
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t} />);
}
});
const deleteCacheMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.deleteCache(id),
onSuccess: () => {
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} cache was cleared!`} t={t}/>);
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} cache was cleared!`} t={t} />);
}
});
@ -269,6 +270,7 @@ const FeedItemDropdown = ({
<Menu as="div">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={() => {
@ -279,14 +281,15 @@ const FeedItemDropdown = ({
text="Are you sure you want to remove this feed? This action cannot be undone."
/>
<DeleteModal
isOpen={deleteCacheModalIsOpen}
toggle={toggleDeleteCacheModal}
buttonRef={cancelCacheModalButtonRef}
deleteAction={() => {
deleteCacheMutation.mutate(feed.id);
}}
title={`Remove feed cache: ${feed.name}`}
text="Are you sure you want to remove the feed cache? This action cannot be undone."
isOpen={deleteCacheModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteCacheModal}
buttonRef={cancelCacheModalButtonRef}
deleteAction={() => {
deleteCacheMutation.mutate(feed.id);
}}
title={`Remove feed cache: ${feed.name}`}
text="Are you sure you want to remove the feed cache? This action cannot be undone."
/>
<Menu.Button className="px-4 py-2">
<EllipsisHorizontalIcon
@ -349,49 +352,49 @@ const FeedItemDropdown = ({
</Menu.Item>
</div>
<div>
<Menu.Item>
{({ active }) => (
<a
href={`${baseUrl()}api/feeds/${feed.id}/latest`}
target="_blank"
rel="noopener noreferrer"
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
>
<DocumentTextIcon
<Menu.Item>
{({ active }) => (
<a
href={`${baseUrl()}api/feeds/${feed.id}/latest`}
target="_blank"
rel="noopener noreferrer"
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
aria-hidden="true"
/>
View latest run
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
>
<DocumentTextIcon
className={classNames(
active ? "bg-red-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
onClick={() => toggleDeleteCacheModal()}
title="Manually clear all feed cache"
aria-hidden="true"
/>
View latest run
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
className={classNames(
active ? "bg-red-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={() => toggleDeleteCacheModal()}
title="Manually clear all feed cache"
>
<ArrowPathIcon
className={classNames(
active ? "text-white" : "text-red-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
className={classNames(
active ? "text-white" : "text-red-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Clear feed cache
</button>
)}
</Menu.Item>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>

View file

@ -171,7 +171,7 @@ const IrcSettings = () => {
? <span className="flex items-center">Collapse <ArrowsPointingInIcon className="ml-1 w-4 h-4"/></span>
: <span className="flex items-center">Expand <ArrowsPointingOutIcon className="ml-1 w-4 h-4"/></span>
}</button>
<div className="relative z-10"><IRCLogsDropdown/></div>
<IRCLogsDropdown/>
</div>
</div>
@ -478,6 +478,7 @@ const ListItemDropdown = ({
>
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={() => {
@ -665,33 +666,11 @@ export const Events = ({ network, channel }: EventsProps) => {
setLogs([]);
}, [settings.scrollOnNewLog]);
// const { handleSubmit, register , resetField } = useForm<IrcMsg>({
// defaultValues: { msg: "" },
// mode: "onBlur"
// });
// const cmdMutation = useMutation({
// mutationFn: (data: SendIrcCmdRequest) => APIClient.irc.sendCmd(data),
// onSuccess: (_, _variables) => {
// resetField("msg");
// },
// onError: () => {
// toast.custom((t) => (
// <Toast type="error" body="Error sending IRC cmd" t={t} />
// ));
// }
// });
// const onSubmit = (msg: IrcMsg) => {
// const payload = { network_id: network.id, nick: network.nick, server: network.server, channel: channel, msg: msg.msg };
// cmdMutation.mutate(payload);
// };
return (
<div
className={classNames(
"dark:bg-gray-800 rounded-lg shadow-lg p-2",
isFullscreen ? "fixed top-0 left-0 w-screen h-screen z-50" : ""
isFullscreen ? "fixed top-0 left-0 right-0 bottom-0 w-screen h-screen z-50" : ""
)}
>
<div className="flex relative">
@ -725,21 +704,6 @@ export const Events = ({ network, channel }: EventsProps) => {
</div>
))}
</div>
{/*<div>*/}
{/* <form onSubmit={handleSubmit(onSubmit)}>*/}
{/* <input*/}
{/* id="msg"*/}
{/* {...(register && register("msg"))}*/}
{/* type="text"*/}
{/* minLength={2}*/}
{/* className={classNames(*/}
{/* "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",*/}
{/* "block w-full dark:bg-gray-900 shadow-sm dark:text-gray-100 sm:text-sm rounded-md"*/}
{/* )}*/}
{/* />*/}
{/* </form>*/}
{/*</div>*/}
</div>
);
};
@ -758,7 +722,7 @@ const IRCLogsDropdown = () => {
}));
return (
<Menu as="div">
<Menu as="div" className="relative">
<Menu.Button>
<button className="flex items-center text-gray-800 dark:text-gray-400 p-1 px-2 rounded shadow bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">
<span className="flex items-center">Options <Cog6ToothIcon className="ml-1 w-4 h-4"/></span>
@ -774,7 +738,7 @@ const IRCLogsDropdown = () => {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 mt-2 bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
className="absolute z-10 right-0 mt-2 bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
>
<div className="p-3">
<Menu.Item>
@ -786,7 +750,6 @@ const IRCLogsDropdown = () => {
/>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>

View file

@ -101,6 +101,7 @@ function DeleteReleases() {
<div className="flex justify-between items-center rounded-md shadow-sm">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteOlderMutation.isLoading}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteOlderReleases}

View file

@ -4,53 +4,13 @@
*/
import { newRidgeState } from "react-ridge-state";
export const InitializeGlobalContext = () => {
const auth_ctx = localStorage.getItem("auth");
if (auth_ctx)
AuthContext.set(JSON.parse(auth_ctx));
const settings_ctx = localStorage.getItem("settings");
if (settings_ctx) {
SettingsContext.set(JSON.parse(settings_ctx));
} else {
// Only check for light theme, otherwise dark theme is the default
SettingsContext.set((state) => ({
...state,
darkTheme: !(
window.matchMedia !== undefined &&
window.matchMedia("(prefers-color-scheme: light)").matches
)
}));
}
const filterList_ctx = localStorage.getItem("filterList");
if (filterList_ctx) {
FilterListContext.set(JSON.parse(filterList_ctx));
}
};
import type { StateWithValue } from "react-ridge-state";
interface AuthInfo {
username: string;
isLoggedIn: boolean;
}
export const AuthContext = newRidgeState<AuthInfo>(
{
username: "",
isLoggedIn: false
},
{
onSet: (new_state) => {
try {
localStorage.setItem("auth", JSON.stringify(new_state));
} catch (e) {
console.log("An error occurred while trying to modify the local auth context state.");
console.log("Error:", e);
}
}
}
);
interface SettingsType {
debug: boolean;
checkForUpdates: boolean;
@ -60,83 +20,111 @@ interface SettingsType {
hideWrappedText: boolean;
}
export const SettingsContext = newRidgeState<SettingsType>(
{
debug: false,
checkForUpdates: true,
darkTheme: true,
scrollOnNewLog: false,
indentLogLines: false,
hideWrappedText: false
},
{
onSet: (new_state) => {
try {
document.documentElement.classList.toggle("dark", new_state.darkTheme);
localStorage.setItem("settings", JSON.stringify(new_state));
} catch (e) {
console.log("An error occurred while trying to modify the local settings context state.");
console.log("Error:", e);
}
}
}
);
export type FilterListState = {
indexerFilter: string[],
indexerFilter: string[];
sortOrder: string;
status: string;
};
// Default values
const AuthContextDefaults: AuthInfo = {
username: "",
isLoggedIn: false
};
const SettingsContextDefaults: SettingsType = {
debug: false,
checkForUpdates: true,
darkTheme: true,
scrollOnNewLog: false,
indentLogLines: false,
hideWrappedText: false
};
const FilterListContextDefaults: FilterListState = {
indexerFilter: [],
sortOrder: "",
status: ""
};
// eslint-disable-next-line
function ContextMerger<T extends {}>(
key: string,
defaults: T,
ctxState: StateWithValue<T>
) {
const storage = localStorage.getItem(key);
if (!storage) {
// Nothing to do. We already have a value thanks to react-ridge-state.
return;
}
try {
const json = JSON.parse(storage);
if (json === null) {
console.warn(`JSON localStorage value for '${key}' context state is null`);
return;
}
Object.keys(defaults).forEach((key) => {
const propName = key as unknown as keyof T;
// Check if JSON in localStorage is missing newly added key
if (!Object.prototype.hasOwnProperty.call(json, key)) {
// ... and default-initialize it.
json[propName] = defaults[propName];
}
});
ctxState.set(json);
} catch (e) {
console.error(`Failed to merge ${key} context state: ${e}`);
}
}
export const InitializeGlobalContext = () => {
ContextMerger<AuthInfo>("auth", AuthContextDefaults, AuthContext);
ContextMerger<SettingsType>(
"settings",
SettingsContextDefaults,
SettingsContext
);
ContextMerger<FilterListState>(
"filterList",
FilterListContextDefaults,
FilterListContext
);
};
function DefaultSetter<T>(name: string, newState: T, prevState: T) {
try {
localStorage.setItem(name, JSON.stringify(newState));
} catch (e) {
console.error(
`An error occurred while trying to modify '${name}' context state: ${e}`
);
console.warn(` --> prevState: ${prevState}`);
console.warn(` --> newState: ${newState}`);
}
}
export const AuthContext = newRidgeState<AuthInfo>(AuthContextDefaults, {
onSet: (newState, prevState) => DefaultSetter("auth", newState, prevState)
});
export const SettingsContext = newRidgeState<SettingsType>(
SettingsContextDefaults,
{
onSet: (newState, prevState) => {
document.documentElement.classList.toggle("dark", newState.darkTheme);
DefaultSetter("settings", newState, prevState);
}
}
);
export const FilterListContext = newRidgeState<FilterListState>(
FilterListContextDefaults,
{
indexerFilter: [],
sortOrder: "",
status: ""
},
{
onSet: (new_state) => {
try {
localStorage.setItem("filterList", JSON.stringify(new_state));
} catch (e) {
console.log("An error occurred while trying to modify the local filter list context state.");
console.log("Error:", e);
}
}
}
);
export type IrcNetworkState = {
id: number;
name: string;
status: string;
};
export type IrcBufferType = "NICK" | "CHANNEL" | "SERVER";
export type IrcBufferState = {
id: number;
name: string;
type: IrcBufferType;
messages: string[];
};
export type IrcState = {
networks: Map<string, IrcNetworkState>;
buffers: Map<string, IrcBufferState>
};
export const IrcContext = newRidgeState<IrcState>(
{
networks: new Map(),
buffers: new Map()
},
{
onSet: (new_state) => {
try {
console.log("set irc state", new_state);
} catch (e) {
console.log("Error:", e);
}
}
onSet: (newState, prevState) => DefaultSetter("filterList", newState, prevState)
}
);