mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
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:
parent
cbf668e87c
commit
2fed48e0dd
23 changed files with 845 additions and 737 deletions
|
@ -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) => {
|
||||
|
|
32
web/src/components/SectionLoader.tsx
Normal file
32
web/src/components/SectionLoader.tsx
Normal 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")} />
|
||||
);
|
||||
}
|
||||
}
|
86
web/src/components/header/Header.tsx
Normal file
86
web/src/components/header/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
59
web/src/components/header/LeftNav.tsx
Normal file
59
web/src/components/header/LeftNav.tsx
Normal 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>
|
||||
);
|
45
web/src/components/header/MobileNav.tsx
Normal file
45
web/src/components/header/MobileNav.tsx
Normal 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>
|
||||
);
|
98
web/src/components/header/RightNav.tsx
Normal file
98
web/src/components/header/RightNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
21
web/src/components/header/_shared.ts
Normal file
21
web/src/components/header/_shared.ts
Normal 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" }
|
||||
];
|
6
web/src/components/header/index.tsx
Normal file
6
web/src/components/header/index.tsx
Normal 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";
|
|
@ -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>
|
||||
|
|
|
@ -62,6 +62,7 @@ function SlideOver<DataType>({
|
|||
{deleteAction && (
|
||||
<DeleteModal
|
||||
isOpen={deleteModalIsOpen}
|
||||
isLoading={isTesting || false}
|
||||
toggle={toggleDeleteModal}
|
||||
buttonRef={cancelModalButtonRef}
|
||||
deleteAction={deleteAction}
|
||||
|
|
|
@ -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 />} />
|
||||
|
|
|
@ -833,6 +833,7 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
|
|||
>
|
||||
<DeleteModal
|
||||
isOpen={deleteModalIsOpen}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
toggle={toggleDeleteModal}
|
||||
buttonRef={cancelModalButtonRef}
|
||||
deleteAction={deleteAction}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -484,6 +484,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
<Menu as="div">
|
||||
<DeleteModal
|
||||
isOpen={deleteModalIsOpen}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
toggle={toggleDeleteModal}
|
||||
buttonRef={cancelModalButtonRef}
|
||||
deleteAction={() => {
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue