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

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

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

* reverted irc expand view to previous design

* forgot to propagate previous z-height fix

* jinxed it

* add license header to new files

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,24 +7,85 @@ import { FC, Fragment, MutableRefObject } from "react";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
interface DeleteModalProps { import { SectionLoader } from "@components/SectionLoader";
isOpen: boolean;
buttonRef: MutableRefObject<HTMLElement | null> | undefined; interface ModalUpperProps {
toggle: () => void;
deleteAction: () => void;
title: string; title: string;
text: string; text: string;
} }
export const DeleteModal: FC<DeleteModalProps> = ({ isOpen, buttonRef, toggle, deleteAction, title, text }) => ( interface ModalLowerProps {
<Transition.Root show={isOpen} as={Fragment}> 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 <Dialog
as="div" as="div"
static static
className="fixed z-10 inset-0 overflow-y-auto" className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={buttonRef} initialFocus={props.buttonRef}
open={isOpen} open={props.isOpen}
onClose={toggle} 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"> <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 <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" 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="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"> <ModalUpper {...props} />
<div className="sm:flex sm:items-start"> <ModalLower {...props} />
<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>
</div> </div>
</Transition.Child> </Transition.Child>
</div> </div>

View file

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

View file

@ -3,25 +3,38 @@
* SPDX-License-Identifier: GPL-2.0-or-later * 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 { baseUrl } from "@utils";
import { Header } from "@components/header";
import { SectionLoader } from "@components/SectionLoader";
import { NotFound } from "@components/alerts/NotFound"; import { NotFound } from "@components/alerts/NotFound";
import { Base } from "@screens/Base";
import { Dashboard } from "@screens/Dashboard";
import { Logs } from "@screens/Logs"; import { Logs } from "@screens/Logs";
import { Filters, FilterDetails } from "@screens/filters";
import { Releases } from "@screens/Releases"; import { Releases } from "@screens/Releases";
import { Settings } from "@screens/Settings"; import { Settings } from "@screens/Settings";
import * as SettingsSubPage from "@screens/settings/index"; import { Dashboard } from "@screens/Dashboard";
import { Login, Onboarding } from "@screens/auth"; 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 }) => ( export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
<BrowserRouter basename={baseUrl()}> <BrowserRouter basename={baseUrl()}>
{isLoggedIn ? ( {isLoggedIn ? (
<Routes> <Routes>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
<Route element={<Base />}> <Route element={<BaseLayout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="logs" element={<Logs />} /> <Route path="logs" element={<Logs />} />
<Route path="releases" element={<Releases />} /> <Route path="releases" element={<Releases />} />

View file

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

View file

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

View file

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

View file

@ -102,10 +102,10 @@ export function FilterActions({ filter, values }: FilterActionsProps) {
{values.actions.length > 0 ? {values.actions.length > 0 ?
<ul className="divide-y divide-gray-200 dark:divide-gray-700"> <ul className="divide-y divide-gray-200 dark:divide-gray-700">
{values.actions.map((action: Action, index: number) => ( {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> </ul>
: <EmptyListState text="No actions yet!"/> : <EmptyListState text="No actions yet!" />
} }
</div> </div>
</Fragment> </Fragment>
@ -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="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate"> <div className="truncate">
<div className="flex text-sm"> <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} {action.name}
</p> </p>
</div> </div>
@ -683,6 +683,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
> >
<DeleteModal <DeleteModal
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
isLoading={removeMutation.isLoading}
buttonRef={cancelButtonRef} buttonRef={cancelButtonRef}
toggle={toggleDeleteModal} toggle={toggleDeleteModal}
deleteAction={() => removeAction(action.id)} 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} /> <TextField name={`actions.${idx}.name`} label="Name" columns={6} />
</div> </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="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between"> <div className="mt-4 pt-4 flex justify-between">
<button <button
type="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} onClick={toggleDeleteModal}
> >
Remove Remove

View file

@ -3,14 +3,14 @@
* SPDX-License-Identifier: GPL-2.0-or-later * 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {NavLink, Route, Routes, useLocation, useNavigate, useParams} from "react-router-dom"; import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import {toast} from "react-hot-toast"; import { toast } from "react-hot-toast";
import {Form, Formik, FormikValues, useFormikContext} from "formik"; import { Form, Formik, FormikValues, useFormikContext } from "formik";
import {z} from "zod"; import { z } from "zod";
import {toFormikValidationSchema} from "zod-formik-adapter"; import { toFormikValidationSchema } from "zod-formik-adapter";
import {ChevronDownIcon, ChevronRightIcon} from "@heroicons/react/24/solid"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
import { import {
CODECS_OPTIONS, CODECS_OPTIONS,
@ -28,9 +28,9 @@ import {
SOURCES_OPTIONS, SOURCES_OPTIONS,
tagsMatchLogicOptions tagsMatchLogicOptions
} from "@app/domain/constants"; } from "@app/domain/constants";
import {APIClient} from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import {useToggle} from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import {classNames} from "@utils"; import { classNames } from "@utils";
import { import {
CheckboxField, CheckboxField,
@ -44,12 +44,13 @@ import {
} from "@components/inputs"; } from "@components/inputs";
import DEBUG from "@components/debug"; import DEBUG from "@components/debug";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import {DeleteModal} from "@components/modals"; import { DeleteModal } from "@components/modals";
import {TitleSubtitle} from "@components/headings"; import { TitleSubtitle } from "@components/headings";
import {RegexTextAreaField, TextAreaAutoResize} from "@components/inputs/input"; import { RegexTextAreaField, TextAreaAutoResize } from "@components/inputs/input";
import {FilterActions} from "./Action"; import { FilterActions } from "./Action";
import {filterKeys} from "./List"; import { filterKeys } from "./List";
import {External} from "@screens/filters/External"; import { External } from "@screens/filters/External";
import { SectionLoader } from "@components/SectionLoader";
interface tabType { interface tabType {
name: string; name: string;
@ -80,8 +81,8 @@ function TabNavLink({ item }: NavLinkProps) {
to={item.href} to={item.href}
end end
className={({ isActive }) => classNames( 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", "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" : "" 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} aria-current={splitLocation[2] === item.href ? "page" : undefined}
> >
@ -95,17 +96,19 @@ interface FormButtonsGroupProps {
deleteAction: () => void; deleteAction: () => void;
reset: () => void; reset: () => void;
dirty?: boolean; dirty?: boolean;
isLoading: boolean;
} }
const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps) => { const FormButtonsGroup = ({ values, deleteAction, reset, isLoading }: FormButtonsGroupProps) => {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const cancelModalButtonRef = useRef(null); const cancelModalButtonRef = useRef(null);
return ( 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 <DeleteModal
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
isLoading={isLoading}
toggle={toggleDeleteModal} toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef} buttonRef={cancelModalButtonRef}
deleteAction={deleteAction} deleteAction={deleteAction}
@ -116,7 +119,7 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
<div className="mt-4 pt-4 flex justify-between"> <div className="mt-4 pt-4 flex justify-between">
<button <button
type="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} onClick={toggleDeleteModal}
> >
Remove Remove
@ -131,7 +134,7 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
e.preventDefault(); e.preventDefault();
reset(); 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 Reset form values
@ -154,19 +157,19 @@ const FormErrorNotification = () => {
useEffect(() => { useEffect(() => {
if (!isValid && !isValidating && isSubmitting) { if (!isValid && !isValidating && isSubmitting) {
console.log("validation errors: ", errors); 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]); }, [isSubmitting, isValid, isValidating]);
return null; 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({ const actionSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
name: z.string(), 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(), client_id: z.number().optional(),
exec_cmd: z.string().optional(), exec_cmd: z.string().optional(),
exec_args: z.string().optional(), exec_args: z.string().optional(),
@ -262,7 +265,7 @@ export function FilterDetails() {
}); });
toast.custom((t) => ( 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} /> <Toast type="success" body={`${filter?.name} was deleted`} t={t} />
)); ));
// redirect // redirect
navigate("/filters"); navigate("/filters");
} }
}); });
if (isLoading) {
return null;
}
if (!filter) { if (!filter) {
return null; return null;
} }
@ -406,6 +404,7 @@ export function FilterDetails() {
{({ values, dirty, resetForm }) => ( {({ values, dirty, resetForm }) => (
<Form> <Form>
<FormErrorNotification /> <FormErrorNotification />
<Suspense fallback={<SectionLoader $size="large" />}>
<Routes> <Routes>
<Route index element={<General />} /> <Route index element={<General />} />
<Route path="movies-tv" element={<MoviesTv />} /> <Route path="movies-tv" element={<MoviesTv />} />
@ -414,7 +413,14 @@ export function FilterDetails() {
<Route path="external" element={<External />} /> <Route path="external" element={<External />} />
<Route path="actions" element={<FilterActions filter={filter} values={values} />} /> <Route path="actions" element={<FilterActions filter={filter} values={values} />} />
</Routes> </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} /> <DEBUG values={values} />
</Form> </Form>
)} )}
@ -426,7 +432,7 @@ export function FilterDetails() {
); );
} }
export function General(){ export function General() {
const { isLoading, data: indexers } = useQuery({ const { isLoading, data: indexers } = useQuery({
queryKey: ["filters", "indexer_list"], queryKey: ["filters", "indexer_list"],
queryFn: APIClient.indexers.getOptions, queryFn: APIClient.indexers.getOptions,
@ -486,7 +492,7 @@ export function MoviesTv() {
</div> </div>
<div className="mt-6"> <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>
</div> </div>
@ -580,8 +586,8 @@ export function Advanced({ values }: AdvancedProps) {
<div className="grid col-span-12 gap-6"> <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." /> <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="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="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 ? ( {values.match_releases ? (
<WarningAlert <WarningAlert
@ -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"> <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">*/} {/*<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="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="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>*/}
<div className="col-span-6"> <div className="col-span-6">

View file

@ -6,11 +6,11 @@
import { Field, FieldArray, FieldArrayRenderProps, FieldProps, useFormikContext } from "formik"; import { Field, FieldArray, FieldArrayRenderProps, FieldProps, useFormikContext } from "formik";
import { NumberField, Select, TextField } from "@components/inputs"; import { NumberField, Select, TextField } from "@components/inputs";
import { TextArea } from "@components/inputs/input"; import { TextArea } from "@components/inputs/input";
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useRef } from "react";
import { EmptyListState } from "@components/emptystates"; import { EmptyListState } from "@components/emptystates";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils"; import { classNames } from "@utils";
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react"; import { Switch as SwitchBasic } from "@headlessui/react";
import { import {
ExternalFilterTypeNameMap, ExternalFilterTypeNameMap,
ExternalFilterTypeOptions, ExternalFilterTypeOptions,
@ -21,20 +21,20 @@ import { DeleteModal } from "@components/modals";
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline"; import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";
export function External() { export function External() {
const {values} = useFormikContext<Filter>(); const { values } = useFormikContext<Filter>();
const newItem: ExternalFilter = { const newItem: ExternalFilter = {
id: values.external.length + 1, id: values.external.length + 1,
index: values.external.length, index: values.external.length,
name: `External ${values.external.length + 1}`, name: `External ${values.external.length + 1}`,
enabled: false, enabled: false,
type: "EXEC", type: "EXEC"
} };
return ( return (
<div className="mt-10"> <div className="mt-10">
<FieldArray name="external"> <FieldArray name="external">
{({remove, push, move}: FieldArrayRenderProps) => ( {({ remove, push, move }: FieldArrayRenderProps) => (
<Fragment> <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 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4"> <div className="ml-4 mt-4">
@ -58,10 +58,10 @@ export function External() {
{values.external.length > 0 {values.external.length > 0
? <ul className="divide-y divide-gray-200 dark:divide-gray-700"> ? <ul className="divide-y divide-gray-200 dark:divide-gray-700">
{values.external.map((f, index: number) => ( {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> </ul>
: <EmptyListState text="No external filters yet!"/> : <EmptyListState text="No external filters yet!" />
} }
</div> </div>
</Fragment> </Fragment>
@ -80,7 +80,7 @@ interface FilterExternalItemProps {
} }
function FilterExternalItem({ idx, external, initialEdit, remove, move }: FilterExternalItemProps) { function FilterExternalItem({ idx, external, initialEdit, remove, move }: FilterExternalItemProps) {
const {values, setFieldValue} = useFormikContext<Filter>(); const { values, setFieldValue } = useFormikContext<Filter>();
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
@ -91,13 +91,13 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
}; };
const moveUp = () => { const moveUp = () => {
move(idx, idx - 1) move(idx, idx - 1);
setFieldValue(`external.${idx}.index`, idx - 1) setFieldValue(`external.${idx}.index`, idx - 1);
}; };
const moveDown = () => { const moveDown = () => {
move(idx, idx + 1) move(idx, idx + 1);
setFieldValue(`external.${idx}.index`, idx + 1) setFieldValue(`external.${idx}.index`, idx + 1);
}; };
return ( return (
@ -131,7 +131,7 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
<Field name={`external.${idx}.enabled`} type="checkbox"> <Field name={`external.${idx}.enabled`} type="checkbox">
{({ {({
field, field,
form: {setFieldValue} form: { setFieldValue }
}: FieldProps) => ( }: FieldProps) => (
<SwitchBasic <SwitchBasic
{...field} {...field}
@ -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="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate"> <div className="truncate">
<div className="flex text-sm"> <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} {external.name}
</p> </p>
</div> </div>
@ -183,28 +183,17 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
</div> </div>
{edit && ( {edit && (
<div className="px-4 py-4 flex items-center sm:px-6 border dark:border-gray-600"> <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 <DeleteModal
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
isLoading={false}
buttonRef={cancelButtonRef} buttonRef={cancelButtonRef}
toggle={toggleDeleteModal} toggle={toggleDeleteModal}
deleteAction={removeAction} deleteAction={removeAction}
title="Remove external filter" title="Remove external filter"
text="Are you sure you want to remove this external filter? This action cannot be undone." 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="w-full">
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">
<Select <Select
name={`external.${idx}.type`} 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>} 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> </div>
<TypeForm external={external} idx={idx}/> <TypeForm external={external} idx={idx} />
<div className="pt-6 divide-y divide-gray-200"> <div className="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between"> <div className="mt-4 pt-4 flex justify-between">
<button <button
type="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} onClick={toggleDeleteModal}
> >
Remove Remove
@ -232,7 +221,9 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
<div> <div>
<button <button
type="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} onClick={toggleEdit}
> >
Close Close
@ -240,7 +231,6 @@ function FilterExternalItem({ idx, external, initialEdit, remove, move }: Filter
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
@ -254,7 +244,7 @@ interface TypeFormProps {
idx: number; idx: number;
} }
const TypeForm = ({external, idx}: TypeFormProps) => { const TypeForm = ({ external, idx }: TypeFormProps) => {
switch (external.type) { switch (external.type) {
case "EXEC": case "EXEC":
return ( return (
@ -265,17 +255,25 @@ const TypeForm = ({external, idx}: TypeFormProps) => {
label="Command" label="Command"
columns={6} columns={6}
placeholder="Absolute path to executable eg. /bin/test" placeholder="Absolute path to executable eg. /bin/test"
tooltip={<div><p>For custom commands you should specify the full path to the binary/program tooltip={
you want to run. And you can include your own static variables:</p><a <div>
href='https://autobrr.com/filters/actions#custom-commands--exec' <p>
className='text-blue-400 visited:text-blue-400' For custom commands you should specify the full path to the binary/program
target='_blank'>https://autobrr.com/filters/actions#custom-commands--exec</a></div>} 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 <TextField
name={`external.${idx}.exec_args`} name={`external.${idx}.exec_args`}
label="Arguments" label="Arguments"
columns={6} columns={6}
placeholder={`Arguments eg. --test "{{ .TorrentName }}"`} placeholder={"Arguments eg. --test \"{{ .TorrentName }}\""}
/> />
</div> </div>
<div className="mt-6 grid grid-cols-12 gap-6"> <div className="mt-6 grid grid-cols-12 gap-6">

View file

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

View file

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

View file

@ -23,7 +23,7 @@ import { DeleteModal } from "@components/modals";
import { FeedUpdateForm } from "@forms/settings/FeedForms"; import { FeedUpdateForm } from "@forms/settings/FeedForms";
import { EmptySimple } from "@components/emptystates"; import { EmptySimple } from "@components/emptystates";
import { ImplementationBadges } from "./Indexer"; import { ImplementationBadges } from "./Indexer";
import {ArrowPathIcon} from "@heroicons/react/24/solid"; import { ArrowPathIcon } from "@heroicons/react/24/solid";
export const feedKeys = { export const feedKeys = {
all: ["feeds"] as const, all: ["feeds"] as const,
@ -64,7 +64,8 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
return sortableItems; return sortableItems;
}, [items, sortConfig]); }, [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 ( if (
sortConfig && sortConfig &&
sortConfig.key === key && sortConfig.key === key &&
@ -139,7 +140,7 @@ function FeedSettings() {
</div> </div>
</li> </li>
{sortedFeeds.items.map((feed) => ( {sortedFeeds.items.map((feed) => (
<ListItem key={feed.id} feed={feed}/> <ListItem key={feed.id} feed={feed} />
))} ))}
</ol> </ol>
</section> </section>
@ -165,7 +166,7 @@ function ListItem({ feed }: ListItemProps) {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() }); queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) }); 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 ( return (
<li key={feed.id} className="text-gray-500 dark:text-gray-400"> <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="grid grid-cols-12 items-center">
<div className="col-span-2 sm:col-span-1 px-6 flex items-center"> <div className="col-span-2 sm:col-span-1 px-6 flex items-center">
@ -254,14 +255,14 @@ const FeedItemDropdown = ({
queryClient.invalidateQueries({ queryKey: feedKeys.lists() }); queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) }); 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({ const deleteCacheMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.deleteCache(id), mutationFn: (id: number) => APIClient.feeds.deleteCache(id),
onSuccess: () => { 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"> <Menu as="div">
<DeleteModal <DeleteModal
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal} toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef} buttonRef={cancelModalButtonRef}
deleteAction={() => { deleteAction={() => {
@ -280,6 +282,7 @@ const FeedItemDropdown = ({
/> />
<DeleteModal <DeleteModal
isOpen={deleteCacheModalIsOpen} isOpen={deleteCacheModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteCacheModal} toggle={toggleDeleteCacheModal}
buttonRef={cancelCacheModalButtonRef} buttonRef={cancelCacheModalButtonRef}
deleteAction={() => { deleteAction={() => {

View file

@ -171,7 +171,7 @@ const IrcSettings = () => {
? <span className="flex items-center">Collapse <ArrowsPointingInIcon className="ml-1 w-4 h-4"/></span> ? <span className="flex items-center">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> : <span className="flex items-center">Expand <ArrowsPointingOutIcon className="ml-1 w-4 h-4"/></span>
}</button> }</button>
<div className="relative z-10"><IRCLogsDropdown/></div> <IRCLogsDropdown/>
</div> </div>
</div> </div>
@ -478,6 +478,7 @@ const ListItemDropdown = ({
> >
<DeleteModal <DeleteModal
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
isLoading={deleteMutation.isLoading}
toggle={toggleDeleteModal} toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef} buttonRef={cancelModalButtonRef}
deleteAction={() => { deleteAction={() => {
@ -665,33 +666,11 @@ export const Events = ({ network, channel }: EventsProps) => {
setLogs([]); setLogs([]);
}, [settings.scrollOnNewLog]); }, [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 ( return (
<div <div
className={classNames( className={classNames(
"dark:bg-gray-800 rounded-lg shadow-lg p-2", "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"> <div className="flex relative">
@ -725,21 +704,6 @@ export const Events = ({ network, channel }: EventsProps) => {
</div> </div>
))} ))}
</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> </div>
); );
}; };
@ -758,7 +722,7 @@ const IRCLogsDropdown = () => {
})); }));
return ( return (
<Menu as="div"> <Menu as="div" className="relative">
<Menu.Button> <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"> <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> <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" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items <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"> <div className="p-3">
<Menu.Item> <Menu.Item>
@ -786,7 +750,6 @@ const IRCLogsDropdown = () => {
/> />
)} )}
</Menu.Item> </Menu.Item>
</div> </div>
</Menu.Items> </Menu.Items>
</Transition> </Transition>

View file

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

View file

@ -4,53 +4,13 @@
*/ */
import { newRidgeState } from "react-ridge-state"; import { newRidgeState } from "react-ridge-state";
import type { StateWithValue } 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));
}
};
interface AuthInfo { interface AuthInfo {
username: string; username: string;
isLoggedIn: boolean; 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 { interface SettingsType {
debug: boolean; debug: boolean;
checkForUpdates: boolean; checkForUpdates: boolean;
@ -60,83 +20,111 @@ interface SettingsType {
hideWrappedText: boolean; 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, debug: false,
checkForUpdates: true, checkForUpdates: true,
darkTheme: true, darkTheme: true,
scrollOnNewLog: false, scrollOnNewLog: false,
indentLogLines: false, indentLogLines: false,
hideWrappedText: 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: [], indexerFilter: [],
sortOrder: "", sortOrder: "",
status: "" status: ""
}, };
{
onSet: (new_state) => { // eslint-disable-next-line
try { function ContextMerger<T extends {}>(
localStorage.setItem("filterList", JSON.stringify(new_state)); key: string,
} catch (e) { defaults: T,
console.log("An error occurred while trying to modify the local filter list context state."); ctxState: StateWithValue<T>
console.log("Error:", e); ) {
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 = { export const FilterListContext = newRidgeState<FilterListState>(
id: number; FilterListContextDefaults,
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(), onSet: (newState, prevState) => DefaultSetter("filterList", newState, prevState)
buffers: new Map()
},
{
onSet: (new_state) => {
try {
console.log("set irc state", new_state);
} catch (e) {
console.log("Error:", e);
}
}
} }
); );