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

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

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

* reverted irc expand view to previous design

* forgot to propagate previous z-height fix

* jinxed it

* add license header to new files

---------

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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>
)}

View file

@ -6,11 +6,11 @@
import { Field, FieldArray, FieldArrayRenderProps, FieldProps, useFormikContext } from "formik";
import { NumberField, Select, TextField } from "@components/inputs";
import { TextArea } from "@components/inputs/input";
import { Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useRef } from "react";
import { EmptyListState } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
import { Switch as SwitchBasic } from "@headlessui/react";
import {
ExternalFilterTypeNameMap,
ExternalFilterTypeOptions,
@ -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">

View file

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

View file

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

View file

@ -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={() => {

View file

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

View file

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

View file

@ -4,53 +4,13 @@
*/
import { newRidgeState } from "react-ridge-state";
export const InitializeGlobalContext = () => {
const auth_ctx = localStorage.getItem("auth");
if (auth_ctx)
AuthContext.set(JSON.parse(auth_ctx));
const settings_ctx = localStorage.getItem("settings");
if (settings_ctx) {
SettingsContext.set(JSON.parse(settings_ctx));
} else {
// Only check for light theme, otherwise dark theme is the default
SettingsContext.set((state) => ({
...state,
darkTheme: !(
window.matchMedia !== undefined &&
window.matchMedia("(prefers-color-scheme: light)").matches
)
}));
}
const filterList_ctx = localStorage.getItem("filterList");
if (filterList_ctx) {
FilterListContext.set(JSON.parse(filterList_ctx));
}
};
import type { StateWithValue } from "react-ridge-state";
interface AuthInfo {
username: string;
isLoggedIn: boolean;
}
export const AuthContext = newRidgeState<AuthInfo>(
{
username: "",
isLoggedIn: false
},
{
onSet: (new_state) => {
try {
localStorage.setItem("auth", JSON.stringify(new_state));
} catch (e) {
console.log("An error occurred while trying to modify the local auth context state.");
console.log("Error:", e);
}
}
}
);
interface SettingsType {
debug: boolean;
checkForUpdates: boolean;
@ -60,83 +20,111 @@ interface SettingsType {
hideWrappedText: boolean;
}
export const SettingsContext = newRidgeState<SettingsType>(
{
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)
}
);