mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +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;
|
||||
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}/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center lg:col-span-9">
|
||||
<SectionLoader $size="large" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</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)}
|
||||
|
@ -712,7 +713,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
|
|||
<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,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import {ReactNode, useEffect, useRef} from "react";
|
||||
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";
|
||||
|
@ -50,6 +50,7 @@ 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
|
||||
|
@ -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,6 +404,7 @@ export function FilterDetails() {
|
|||
{({ values, dirty, resetForm }) => (
|
||||
<Form>
|
||||
<FormErrorNotification />
|
||||
<Suspense fallback={<SectionLoader $size="large" />}>
|
||||
<Routes>
|
||||
<Route index element={<General />} />
|
||||
<Route path="movies-tv" element={<MoviesTv />} />
|
||||
|
@ -414,7 +413,14 @@ export function FilterDetails() {
|
|||
<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>
|
||||
<FormButtonsGroup
|
||||
values={values}
|
||||
deleteAction={deleteAction}
|
||||
dirty={dirty}
|
||||
reset={resetForm}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DEBUG values={values} />
|
||||
</Form>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
|
@ -28,8 +28,8 @@ export function External() {
|
|||
index: values.external.length,
|
||||
name: `External ${values.external.length + 1}`,
|
||||
enabled: false,
|
||||
type: "EXEC",
|
||||
}
|
||||
type: "EXEC"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-10">
|
||||
|
@ -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 (
|
||||
|
@ -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}
|
||||
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."
|
||||
/>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<Select
|
||||
name={`external.${idx}.type`}
|
||||
|
@ -223,7 +212,7 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
|
|||
<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>
|
||||
)}
|
||||
|
@ -265,17 +255,25 @@ const TypeForm = ({external, idx}: TypeFormProps) => {
|
|||
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>}
|
||||
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 }}"`}
|
||||
placeholder={"Arguments eg. --test \"{{ .TorrentName }}\""}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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 &&
|
||||
|
@ -269,6 +270,7 @@ const FeedItemDropdown = ({
|
|||
<Menu as="div">
|
||||
<DeleteModal
|
||||
isOpen={deleteModalIsOpen}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
toggle={toggleDeleteModal}
|
||||
buttonRef={cancelModalButtonRef}
|
||||
deleteAction={() => {
|
||||
|
@ -280,6 +282,7 @@ const FeedItemDropdown = ({
|
|||
/>
|
||||
<DeleteModal
|
||||
isOpen={deleteCacheModalIsOpen}
|
||||
isLoading={deleteMutation.isLoading}
|
||||
toggle={toggleDeleteCacheModal}
|
||||
buttonRef={cancelCacheModalButtonRef}
|
||||
deleteAction={() => {
|
||||
|
|
|
@ -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>(
|
||||
{
|
||||
export type FilterListState = {
|
||||
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
|
||||
},
|
||||
{
|
||||
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[],
|
||||
sortOrder: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export const FilterListContext = newRidgeState<FilterListState>(
|
||||
{
|
||||
const FilterListContextDefaults: FilterListState = {
|
||||
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);
|
||||
};
|
||||
|
||||
// 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 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>(
|
||||
export const FilterListContext = newRidgeState<FilterListState>(
|
||||
FilterListContextDefaults,
|
||||
{
|
||||
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