mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
chore(web): relocations and cleanups (#957)
* delete manifest (vite-plugin-pwa generates it) * fix upper case letter on screen components * fix imports of screens components missing upper case * remove default export from Base.tsx * move RegexPlayground to settings import * replace some relative path imports * remove React and ununsed imports * small alignments on vite.config.ts * move Dashboard and Releases to screens * move filters/index.tsx to filters/index.ts * remove default export from APIKeyAddForm * remove default export from FilterAddForm * organize imports and exports for the router * add .vscode workspace to gitignore * some touchs on .gitignore file * fix some eslint rules
This commit is contained in:
parent
72bb2ddadb
commit
c7ec93722b
41 changed files with 187 additions and 230 deletions
|
@ -3,9 +3,9 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { baseUrl, sseBaseUrl } from "../utils";
|
||||
import { AuthContext } from "../utils/Context";
|
||||
import { GithubRelease } from "../types/Update";
|
||||
import { baseUrl, sseBaseUrl } from "@utils";
|
||||
import { AuthContext } from "@utils/Context";
|
||||
import { GithubRelease } from "@app/types/Update";
|
||||
|
||||
interface ConfigType {
|
||||
body?: BodyInit | Record<string, unknown> | unknown;
|
||||
|
|
|
@ -9,10 +9,10 @@ import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
|
|||
import { ClockIcon, ExclamationCircleIcon, NoSymbolIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { classNames, simplifyDate } from "@utils";
|
||||
import { Tooltip } from "../tooltips/Tooltip";
|
||||
import { Tooltip } from "@components/tooltips/Tooltip";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { filterKeys } from "@screens/filters/list";
|
||||
import { filterKeys } from "@screens/filters/List";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { RingResizeSpinner } from "@components/Icons";
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { Fragment } from "react";
|
||||
import { Field, FieldProps } from "formik";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
|
||||
|
|
|
@ -8,7 +8,7 @@ import { Field } from "formik";
|
|||
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
|
||||
import { OptionBasicTyped } from "@domain/constants";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import { CustomTooltip } from "../tooltips/CustomTooltip";
|
||||
import { CustomTooltip } from "@components/tooltips/CustomTooltip";
|
||||
|
||||
interface SelectFieldProps<T> {
|
||||
name: string;
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Field } from "formik";
|
|||
import { Switch as HeadlessSwitch } from "@headlessui/react";
|
||||
|
||||
import { classNames } from "@utils";
|
||||
import { CustomTooltip } from "../tooltips/CustomTooltip";
|
||||
import { CustomTooltip } from "@components/tooltips/CustomTooltip";
|
||||
|
||||
type SwitchProps<V = unknown> = {
|
||||
label?: string
|
||||
|
@ -137,4 +137,4 @@ const SwitchGroup = ({
|
|||
</HeadlessSwitch.Group>
|
||||
);
|
||||
|
||||
export { SwitchGroup };
|
||||
export { SwitchGroup };
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { FC, forwardRef, ReactNode } from "react";
|
||||
import { FC, forwardRef, ReactNode } from "react";
|
||||
import { DeepMap, FieldError, Path, RegisterOptions, UseFormRegister } from "react-hook-form";
|
||||
import { classNames, get } from "@utils";
|
||||
import { useToggle } from "@hooks/hooks";
|
||||
|
@ -194,4 +194,3 @@ export const PasswordInput = <TFormValues extends Record<string, unknown>>({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { FC, Fragment } from "react";
|
||||
import { FC, Fragment, MutableRefObject } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface DeleteModalProps {
|
||||
isOpen: boolean;
|
||||
buttonRef: React.MutableRefObject<HTMLElement | null> | undefined;
|
||||
buttonRef: MutableRefObject<HTMLElement | null> | undefined;
|
||||
toggle: () => void;
|
||||
deleteAction: () => void;
|
||||
title: string;
|
||||
|
@ -94,4 +94,4 @@ export const DeleteModal: FC<DeleteModalProps> = ({ isOpen, buttonRef, toggle, d
|
|||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
);
|
||||
|
|
|
@ -3,15 +3,15 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { Fragment, useRef } from "react";
|
||||
import { Fragment, useRef, ReactNode, ReactElement } from "react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Form, Formik } from "formik";
|
||||
import type { FormikValues } from "formik";
|
||||
|
||||
import DEBUG from "../debug";
|
||||
import DEBUG from "@components/debug";
|
||||
import { useToggle } from "@hooks/hooks";
|
||||
import { DeleteModal } from "../modals";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
import { classNames } from "@utils";
|
||||
|
||||
interface SlideOverProps<DataType> {
|
||||
|
@ -21,14 +21,14 @@ interface SlideOverProps<DataType> {
|
|||
onSubmit: (values?: DataType) => void;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
children?: (values: DataType) => React.ReactNode;
|
||||
children?: (values: DataType) => ReactNode;
|
||||
deleteAction?: () => void;
|
||||
type: "CREATE" | "UPDATE";
|
||||
testFn?: (data: unknown) => void;
|
||||
isTesting?: boolean;
|
||||
isTestSuccessful?: boolean;
|
||||
isTestError?: boolean;
|
||||
extraButtons?: (values: DataType) => React.ReactNode;
|
||||
extraButtons?: (values: DataType) => ReactNode;
|
||||
}
|
||||
|
||||
function SlideOver<DataType>({
|
||||
|
@ -46,7 +46,7 @@ function SlideOver<DataType>({
|
|||
isTestSuccessful,
|
||||
isTestError,
|
||||
extraButtons
|
||||
}: SlideOverProps<DataType>): React.ReactElement {
|
||||
}: SlideOverProps<DataType>): ReactElement {
|
||||
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
|
||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
||||
|
||||
|
@ -215,4 +215,4 @@ function SlideOver<DataType>({
|
|||
);
|
||||
}
|
||||
|
||||
export { SlideOver };
|
||||
export { SlideOver };
|
||||
|
|
|
@ -4,31 +4,18 @@
|
|||
*/
|
||||
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { Login } from "@screens/auth/login";
|
||||
import { Onboarding } from "@screens/auth/onboarding";
|
||||
import Base from "@screens/Base";
|
||||
import { Dashboard } from "@screens/dashboard";
|
||||
import { FilterDetails, Filters } from "@screens/filters";
|
||||
import { Logs } from "@screens/Logs";
|
||||
import { Releases } from "@screens/releases";
|
||||
import Settings from "@screens/Settings";
|
||||
import {
|
||||
APISettings,
|
||||
ApplicationSettings,
|
||||
DownloadClientSettings,
|
||||
FeedSettings,
|
||||
IndexerSettings,
|
||||
IrcSettings,
|
||||
LogSettings,
|
||||
NotificationSettings,
|
||||
ReleaseSettings
|
||||
} from "@screens/settings/index";
|
||||
import { RegexPlayground } from "@screens/settings/RegexPlayground";
|
||||
import { NotFound } from "@components/alerts/NotFound";
|
||||
|
||||
import { baseUrl } from "@utils";
|
||||
|
||||
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 { Login, Onboarding } from "@screens/auth";
|
||||
|
||||
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
||||
<BrowserRouter basename={baseUrl()}>
|
||||
{isLoggedIn ? (
|
||||
|
@ -43,16 +30,16 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
|||
<Route path=":filterId/*" element={<FilterDetails />} />
|
||||
</Route>
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<ApplicationSettings />} />
|
||||
<Route path="logs" element={<LogSettings />} />
|
||||
<Route path="api-keys" element={<APISettings />} />
|
||||
<Route path="indexers" element={<IndexerSettings />} />
|
||||
<Route path="feeds" element={<FeedSettings />} />
|
||||
<Route path="irc" element={<IrcSettings />} />
|
||||
<Route path="clients" element={<DownloadClientSettings />} />
|
||||
<Route path="notifications" element={<NotificationSettings />} />
|
||||
<Route path="releases" element={<ReleaseSettings />} />
|
||||
<Route path="regex-playground" element={<RegexPlayground />} />
|
||||
<Route index element={<SettingsSubPage.Application />} />
|
||||
<Route path="logs" element={<SettingsSubPage.Logs />} />
|
||||
<Route path="api-keys" element={<SettingsSubPage.Api />} />
|
||||
<Route path="indexers" element={<SettingsSubPage.Indexer />} />
|
||||
<Route path="feeds" element={<SettingsSubPage.Feed />} />
|
||||
<Route path="irc" element={<SettingsSubPage.Irc />} />
|
||||
<Route path="clients" element={<SettingsSubPage.DownloadClient />} />
|
||||
<Route path="notifications" element={<SettingsSubPage.Notification />} />
|
||||
<Route path="releases" element={<SettingsSubPage.Release />} />
|
||||
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
@ -63,4 +50,4 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
|||
</Routes>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
);
|
||||
);
|
||||
|
|
|
@ -15,14 +15,14 @@ import { useNavigate } from "react-router-dom";
|
|||
import { APIClient } from "@api/APIClient";
|
||||
import DEBUG from "@components/debug";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { filterKeys } from "@screens/filters/list";
|
||||
import { filterKeys } from "@screens/filters/List";
|
||||
|
||||
interface filterAddFormProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
||||
export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const mutation = useMutation({
|
||||
|
@ -169,5 +169,3 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
|||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterAddForm;
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
export { default as FilterAddForm } from "./filters/FilterAddForm";
|
||||
export { FilterAddForm } from "./filters/FilterAddForm";
|
||||
|
||||
export { DownloadClientAddForm, DownloadClientUpdateForm } from "./settings/DownloadClientForms";
|
||||
export { IndexerAddForm, IndexerUpdateForm } from "./settings/IndexerForms";
|
||||
|
|
|
@ -21,20 +21,20 @@ interface apiKeyAddFormProps {
|
|||
toggle: () => void;
|
||||
}
|
||||
|
||||
function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
|
||||
export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (apikey: APIKey) => APIClient.apikeys.create(apikey),
|
||||
onSuccess: (_, key) => {
|
||||
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
|
||||
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`API key ${key.name} was added`} t={t}/>);
|
||||
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const handleSubmit = (data: unknown) => mutation.mutate(data as APIKey);
|
||||
const validate = (values: FormikValues) => {
|
||||
const errors = {} as FormikErrors<FormikValues>;
|
||||
|
@ -156,5 +156,3 @@ function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
|
|||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default APIKeyAddForm;
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { Fragment, useRef, useState } from "react";
|
||||
import { Fragment, useRef, useState, ReactElement } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
|
@ -24,7 +24,7 @@ import {
|
|||
SwitchGroupWide,
|
||||
TextFieldWide
|
||||
} from "@components/inputs";
|
||||
import DownloadClient, { clientKeys } from "@screens/settings/DownloadClient";
|
||||
import { clientKeys } from "@screens/settings/DownloadClient";
|
||||
import { SelectFieldWide } from "@components/inputs/input_wide";
|
||||
|
||||
interface InitialValuesSettings {
|
||||
|
@ -181,7 +181,7 @@ function FormFieldsPorla() {
|
|||
required={true}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<SwitchGroupWide name="tls" label="TLS" />
|
||||
|
||||
<PasswordFieldWide name="settings.apikey" label="Auth token" required={true}/>
|
||||
|
@ -322,7 +322,7 @@ function FormFieldsSabnzbd() {
|
|||
}
|
||||
|
||||
export interface componentMapType {
|
||||
[key: string]: React.ReactElement;
|
||||
[key: string]: ReactElement;
|
||||
}
|
||||
|
||||
export const componentMap: componentMapType = {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
|
||||
|
@ -25,8 +25,8 @@ import { feedKeys } from "@screens/settings/Feed";
|
|||
import { indexerKeys } from "@screens/settings/Indexer";
|
||||
|
||||
const Input = (props: InputProps) => (
|
||||
<components.Input
|
||||
{...props}
|
||||
<components.Input
|
||||
{...props}
|
||||
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
|
||||
className="text-gray-400 dark:text-gray-100"
|
||||
children={props.children}
|
||||
|
@ -34,15 +34,15 @@ const Input = (props: InputProps) => (
|
|||
);
|
||||
|
||||
const Control = (props: ControlProps) => (
|
||||
<components.Control
|
||||
{...props}
|
||||
<components.Control
|
||||
{...props}
|
||||
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
children={props.children}
|
||||
/>
|
||||
);
|
||||
|
||||
const Menu = (props: MenuProps) => (
|
||||
<components.Menu
|
||||
<components.Menu
|
||||
{...props}
|
||||
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm cursor-pointer"
|
||||
children={props.children}
|
||||
|
@ -50,7 +50,7 @@ const Menu = (props: MenuProps) => (
|
|||
);
|
||||
|
||||
const Option = (props: OptionProps) => (
|
||||
<components.Option
|
||||
<components.Option
|
||||
{...props}
|
||||
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900 cursor-pointer"
|
||||
children={props.children}
|
||||
|
@ -521,7 +521,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
|||
options={data && data.sort((a, b) => a.name.localeCompare(b.name)).map(v => ({
|
||||
label: v.name,
|
||||
value: v.identifier
|
||||
}))}
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
@ -634,7 +634,7 @@ function TestApiButton({ values, show }: TestApiButtonProps) {
|
|||
id: values.id,
|
||||
api_key: values.settings.api_key
|
||||
};
|
||||
|
||||
|
||||
if (values.settings.api_user) {
|
||||
req.api_user = values.settings.api_user;
|
||||
}
|
||||
|
@ -833,4 +833,4 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
)}
|
||||
</SlideOver>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
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";
|
||||
|
@ -30,7 +30,7 @@ const nav: Array<NavItem> = [
|
|||
{ name: "Logs", path: "/logs" }
|
||||
];
|
||||
|
||||
export default function Base() {
|
||||
export const Base = () => {
|
||||
const authContext = AuthContext.useValue();
|
||||
|
||||
const { data } = useQuery({
|
||||
|
@ -250,4 +250,4 @@ export default function Base() {
|
|||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { Stats } from "./Stats";
|
||||
import { ActivityTable } from "./ActivityTable";
|
||||
import { Stats } from "./dashboard/Stats";
|
||||
import { ActivityTable } from "./dashboard/ActivityTable";
|
||||
|
||||
export const Dashboard = () => (
|
||||
<main className="py-10">
|
||||
|
@ -13,4 +13,4 @@ export const Dashboard = () => (
|
|||
<ActivityTable />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
);
|
|
@ -4,7 +4,6 @@
|
|||
*/
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import format from "date-fns/format";
|
||||
import { DebounceInput } from "react-debounce-input";
|
||||
import {
|
||||
|
@ -49,7 +48,7 @@ export const Logs = () => {
|
|||
|
||||
const [logs, setLogs] = useState<LogEvent[]>([]);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [regexPattern, setRegexPattern] = useState<RegExp | null>(null);
|
||||
const [_regexPattern, setRegexPattern] = useState<RegExp | null>(null);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogEvent[]>([]);
|
||||
const [isInvalidRegex, setIsInvalidRegex] = useState(false);
|
||||
|
||||
|
@ -170,7 +169,7 @@ export const Logs = () => {
|
|||
};
|
||||
|
||||
export const LogFiles = () => {
|
||||
const { isLoading, data } = useQuery({
|
||||
const { data } = useQuery({
|
||||
queryKey: ["log-files"],
|
||||
queryFn: () => APIClient.logs.files(),
|
||||
retry: false,
|
||||
|
@ -323,7 +322,7 @@ const LogsDropdown = () => {
|
|||
>
|
||||
<div className="p-3">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
{() => (
|
||||
<Checkbox
|
||||
label="Scroll to bottom on new message"
|
||||
value={settings.scrollOnNewLog}
|
||||
|
@ -332,7 +331,7 @@ const LogsDropdown = () => {
|
|||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
{() => (
|
||||
<Checkbox
|
||||
label="Indent log lines"
|
||||
description="Indent each log line according to their respective starting position."
|
||||
|
@ -342,7 +341,7 @@ const LogsDropdown = () => {
|
|||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
{() => (
|
||||
<Checkbox
|
||||
label="Hide wrapped text"
|
||||
description="Hides text that is meant to be wrapped."
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { ReleaseTable } from "./ReleaseTable";
|
||||
import { ReleaseTable } from "./releases/ReleaseTable";
|
||||
|
||||
export const Releases = () => (
|
||||
<main>
|
||||
|
@ -16,4 +16,4 @@ export const Releases = () => (
|
|||
<ReleaseTable />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
);
|
|
@ -83,7 +83,7 @@ function SidebarNav({ subNavigation }: SidebarNavProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
export function Settings() {
|
||||
return (
|
||||
<main>
|
||||
<header className="py-10">
|
||||
|
@ -103,4 +103,3 @@ export default function Settings() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ export const Onboarding = () => {
|
|||
|
||||
if (!values.username)
|
||||
obj.username = "Required";
|
||||
|
||||
|
||||
if (!values.password1)
|
||||
obj.password1 = "Required";
|
||||
|
||||
|
@ -32,7 +32,7 @@ export const Onboarding = () => {
|
|||
|
||||
if (values.password1 !== values.password2)
|
||||
obj.password2 = "Passwords don't match!";
|
||||
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
|
@ -83,4 +83,3 @@ export const Onboarding = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,5 +3,5 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
export { default as Filters } from "./list";
|
||||
export { default as FilterDetails } from "./details";
|
||||
export { Login } from "./Login";
|
||||
export { Onboarding } from "./Onboarding";
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Field, FieldArray, FieldProps, FormikValues, useFormikContext } from "formik";
|
||||
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
|
||||
|
@ -24,7 +24,7 @@ import { EmptyListState } from "@components/emptystates";
|
|||
import { useToggle } from "@hooks/hooks";
|
||||
import { classNames } from "@utils";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
import { CollapsableSection } from "./details";
|
||||
import { CollapsableSection } from "./Details";
|
||||
import { TextArea } from "@components/inputs/input";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
|
||||
|
@ -121,7 +121,7 @@ interface TypeFormProps {
|
|||
|
||||
const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
|
||||
const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
|
||||
const fieldName = `actions.${idx}.client_id`;
|
||||
|
||||
|
@ -728,4 +728,4 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
|
|||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, ReactNode } 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";
|
||||
|
@ -11,7 +11,6 @@ import { Form, Formik, FormikValues, useFormikContext } from "formik";
|
|||
import { z } from "zod";
|
||||
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
|
||||
import {
|
||||
CODECS_OPTIONS,
|
||||
|
@ -47,10 +46,9 @@ import DEBUG from "@components/debug";
|
|||
import Toast from "@components/notifications/Toast";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
import { TitleSubtitle } from "@components/headings";
|
||||
import { RegexTextAreaField, TextArea } from "@components/inputs/input";
|
||||
import { TextAreaAutoResize } from "../../components/inputs/input";
|
||||
import { FilterActions } from "./action";
|
||||
import { filterKeys } from "./list";
|
||||
import { RegexTextAreaField, TextArea, TextAreaAutoResize } from "@components/inputs/input";
|
||||
import { FilterActions } from "./Action";
|
||||
import { filterKeys } from "./List";
|
||||
|
||||
interface tabType {
|
||||
name: string;
|
||||
|
@ -214,7 +212,7 @@ const schema = z.object({
|
|||
actions: z.array(actionSchema)
|
||||
});
|
||||
|
||||
export default function FilterDetails() {
|
||||
export function FilterDetails() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { filterId } = useParams<{ filterId: string }>();
|
||||
|
@ -223,6 +221,7 @@ export default function FilterDetails() {
|
|||
navigate("/filters");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const id = parseInt(filterId!);
|
||||
|
||||
const { isLoading, data: filter } = useQuery({
|
||||
|
@ -695,7 +694,7 @@ function WarningAlert({ text, alert, colors }: WarningAlertProps) {
|
|||
interface CollapsableSectionProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
|
@ -795,4 +794,3 @@ export function External() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,26 +77,26 @@ const FilterListReducer = (state: FilterListState, action: Actions): FilterListS
|
|||
}
|
||||
};
|
||||
|
||||
export default function Filters() {
|
||||
export function Filters() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createFilterIsOpen, setCreateFilterIsOpen] = useState(false);
|
||||
const toggleCreateFilter = () => {
|
||||
setCreateFilterIsOpen(!createFilterIsOpen);
|
||||
};
|
||||
};
|
||||
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importJson, setImportJson] = useState("");
|
||||
|
||||
|
||||
// This function handles the import of a filter from a JSON string
|
||||
const handleImportJson = async () => {
|
||||
try {
|
||||
const importedData = JSON.parse(importJson);
|
||||
|
||||
|
||||
// Extract the filter data and name from the imported object
|
||||
const importedFilter = importedData.data;
|
||||
const filterName = importedData.name;
|
||||
|
||||
|
||||
// Check if the required properties are present and add them with default values if they are missing
|
||||
const requiredProperties = ["resolutions", "sources", "codecs", "containers"];
|
||||
requiredProperties.forEach((property) => {
|
||||
|
@ -104,10 +104,10 @@ export default function Filters() {
|
|||
importedFilter[property] = [];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Fetch existing filters from the API
|
||||
const existingFilters = await APIClient.filters.getAll();
|
||||
|
||||
|
||||
// Create a unique filter title by appending an incremental number if title is taken by another filter
|
||||
let nameCounter = 0;
|
||||
let uniqueFilterName = filterName;
|
||||
|
@ -115,18 +115,18 @@ export default function Filters() {
|
|||
nameCounter++;
|
||||
uniqueFilterName = `${filterName}-${nameCounter}`;
|
||||
}
|
||||
|
||||
|
||||
// Create a new filter using the API
|
||||
const newFilter: Filter = {
|
||||
...importedFilter,
|
||||
name: uniqueFilterName
|
||||
};
|
||||
|
||||
|
||||
await APIClient.filters.create(newFilter);
|
||||
|
||||
|
||||
// Update the filter list
|
||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
||||
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="Filter imported successfully." t={t} />);
|
||||
setShowImportModal(false);
|
||||
} catch (error) {
|
||||
|
@ -135,7 +135,7 @@ export default function Filters() {
|
|||
toast.custom((t) => <Toast type="error" body="Failed to import JSON data. Please check your input." t={t} />);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<main>
|
||||
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter} />
|
||||
|
@ -369,9 +369,9 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
external_webhook_data: any;
|
||||
external_webhook_expect_status: any;
|
||||
};
|
||||
|
||||
|
||||
const completeFilter = await APIClient.filters.getByID(filter.id) as Partial<CompleteFilterType>;
|
||||
|
||||
|
||||
// Extract the filter name and remove unwanted properties
|
||||
const title = completeFilter.name;
|
||||
delete completeFilter.name;
|
||||
|
@ -389,7 +389,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
delete completeFilter.external_webhook_host;
|
||||
delete completeFilter.external_webhook_data;
|
||||
delete completeFilter.external_webhook_expect_status;
|
||||
|
||||
|
||||
// Remove properties with default values from the exported filter to minimize the size of the JSON string
|
||||
["enabled", "priority", "smart_episode", "resolutions", "sources", "codecs", "containers", "tags_match_logic", "except_tags_match_logic"].forEach((key) => {
|
||||
const value = completeFilter[key as keyof CompleteFilterType];
|
||||
|
@ -400,7 +400,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
} else if (["tags_match_logic", "except_tags_match_logic"].includes(key) && value === "ANY") {
|
||||
delete completeFilter[key as keyof CompleteFilterType];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create a JSON string from the filter data, including a name and version
|
||||
const json = JSON.stringify(
|
||||
|
@ -412,7 +412,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
null,
|
||||
4
|
||||
);
|
||||
|
||||
|
||||
const finalJson = discordFormat ? "```JSON\n" + json + "\n```" : json;
|
||||
|
||||
const copyTextToClipboard = (text: string) => {
|
||||
|
@ -423,7 +423,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
|
||||
try {
|
||||
const successful = document.execCommand("copy");
|
||||
if (successful) {
|
||||
|
@ -435,10 +435,10 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
console.error("Unable to copy text", err);
|
||||
toast.custom((t) => <Toast type="error" body="Failed to copy JSON to clipboard." t={t} />);
|
||||
}
|
||||
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(finalJson).then(() => {
|
||||
toast.custom((t) => <Toast type="success" body="Filter copied to clipboard." t={t} />);
|
||||
|
@ -448,7 +448,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
} else {
|
||||
copyTextToClipboard(finalJson);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.custom((t) => <Toast type="error" body="Failed to get filter data." t={t} />);
|
||||
|
@ -567,7 +567,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
|||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Export JSON to Discord
|
||||
Export JSON to Discord
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
@ -726,7 +726,7 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
|||
)
|
||||
}
|
||||
>
|
||||
Actions: {filter.actions_count}
|
||||
Actions: {filter.actions_count}
|
||||
</span>
|
||||
</span>
|
||||
{filter.actions_count === 0 && (
|
||||
|
@ -802,7 +802,7 @@ function FilterIndexers({ indexers }: FilterIndexersProps) {
|
|||
className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
|
||||
title={res.map(v => v.name).toString()}
|
||||
>
|
||||
+{indexers.length - 2}
|
||||
+{indexers.length - 2}
|
||||
</span>
|
||||
</>
|
||||
);
|
7
web/src/screens/filters/index.ts
Normal file
7
web/src/screens/filters/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
export { Filters } from "./List";
|
||||
export { FilterDetails } from "./Details";
|
|
@ -75,4 +75,4 @@ function ActionSettings() {
|
|||
);
|
||||
}
|
||||
|
||||
export default ActionSettings;
|
||||
export default ActionSettings;
|
||||
|
|
|
@ -10,7 +10,7 @@ import { TrashIcon } from "@heroicons/react/24/outline";
|
|||
|
||||
import { KeyField } from "@components/fields/text";
|
||||
import { DeleteModal } from "@components/modals";
|
||||
import APIKeyAddForm from "@forms/settings/APIKeyAddForm";
|
||||
import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { useToggle } from "@hooks/hooks";
|
||||
|
|
|
@ -51,7 +51,7 @@ function useSort(items: ListItemProps["clients"][], config?: SortConfig) {
|
|||
sortableItems.sort((a, b) => {
|
||||
const aValue = sortConfig.key === "enabled" ? (a[sortConfig.key] ?? false) as number | boolean | string : a[sortConfig.key] as number | boolean | string;
|
||||
const bValue = sortConfig.key === "enabled" ? (b[sortConfig.key] ?? false) as number | boolean | string : b[sortConfig.key] as number | boolean | string;
|
||||
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortConfig.direction === "ascending" ? -1 : 1;
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ function useSort(items: ListItemProps["clients"][], config?: SortConfig) {
|
|||
return sortConfig.direction === "ascending" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
return sortableItems;
|
||||
}, [items, sortConfig]);
|
||||
|
@ -80,7 +80,7 @@ function useSort(items: ListItemProps["clients"][], config?: SortConfig) {
|
|||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
return sortConfig.direction === "ascending" ? "↑" : "↓";
|
||||
};
|
||||
|
||||
|
@ -194,7 +194,7 @@ function DownloadClientSettings() {
|
|||
onClick={() => sortedClients.requestSort("enabled")}>
|
||||
Enabled <span className="sort-indicator">{sortedClients.getSortIndicator("enabled")}</span>
|
||||
</div>
|
||||
<div
|
||||
<div
|
||||
className="col-span-6 sm:col-span-4 lg:col-span-4 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => sortedClients.requestSort("name")}
|
||||
>
|
||||
|
@ -225,4 +225,4 @@ function DownloadClientSettings() {
|
|||
);
|
||||
}
|
||||
|
||||
export default DownloadClientSettings;
|
||||
export default DownloadClientSettings;
|
||||
|
|
|
@ -50,7 +50,7 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
|
|||
sortableItems.sort((a, b) => {
|
||||
const aValue = sortConfig.key === "enabled" ? (a[sortConfig.key] ?? false) as number | boolean | string : a[sortConfig.key] as number | boolean | string;
|
||||
const bValue = sortConfig.key === "enabled" ? (b[sortConfig.key] ?? false) as number | boolean | string : b[sortConfig.key] as number | boolean | string;
|
||||
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortConfig.direction === "ascending" ? -1 : 1;
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
|
|||
return sortConfig.direction === "ascending" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
return sortableItems;
|
||||
}, [items, sortConfig]);
|
||||
|
@ -73,13 +73,13 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
|
|||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
|
||||
|
||||
const getSortIndicator = (key: keyof ListItemProps["feed"]) => {
|
||||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
return sortConfig.direction === "ascending" ? "↑" : "↓";
|
||||
};
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ function useSort(items: ListItemProps["indexer"][], config?: SortConfig) {
|
|||
sortableItems.sort((a, b) => {
|
||||
const aValue = sortConfig.key === "enabled" ? (a[sortConfig.key] ?? false) as number | boolean | string : a[sortConfig.key] as number | boolean | string;
|
||||
const bValue = sortConfig.key === "enabled" ? (b[sortConfig.key] ?? false) as number | boolean | string : b[sortConfig.key] as number | boolean | string;
|
||||
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortConfig.direction === "ascending" ? -1 : 1;
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ function useSort(items: ListItemProps["indexer"][], config?: SortConfig) {
|
|||
return sortConfig.direction === "ascending" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
return sortableItems;
|
||||
}, [items, sortConfig]);
|
||||
|
@ -69,7 +69,7 @@ function useSort(items: ListItemProps["indexer"][], config?: SortConfig) {
|
|||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
return sortConfig.direction === "ascending" ? "↑" : "↓";
|
||||
};
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { APIClient } from "@api/APIClient";
|
|||
import { GithubRelease } from "@app/types/Update";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { LogLevelOptions, SelectOption } from "@domain/constants";
|
||||
import { LogFiles } from "../Logs";
|
||||
import { LogFiles } from "@screens/Logs";
|
||||
|
||||
interface RowItemProps {
|
||||
label: string;
|
||||
|
@ -195,4 +195,4 @@ function LogSettings() {
|
|||
);
|
||||
}
|
||||
|
||||
export default LogSettings;
|
||||
export default LogSettings;
|
||||
|
|
|
@ -123,13 +123,13 @@ function ListItem({ notification }: ListItemProps) {
|
|||
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const onToggleMutation = (newState: boolean) => {
|
||||
mutation.mutate({
|
||||
...notification,
|
||||
enabled: newState
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={notification.id} className="text-gray-500 dark:text-gray-400">
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export const RegexPlayground = () => {
|
||||
const RegexPlayground = () => {
|
||||
const regexRef = useRef<HTMLInputElement>(null);
|
||||
const [output, setOutput] = useState<Array<React.ReactElement>>();
|
||||
|
||||
const onInput = (text: string) => {
|
||||
if (!regexRef || !regexRef.current)
|
||||
return;
|
||||
|
||||
|
||||
const regexp = new RegExp(regexRef.current.value, "g");
|
||||
|
||||
const results: Array<React.ReactElement> = [];
|
||||
|
@ -50,7 +50,7 @@ export const RegexPlayground = () => {
|
|||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (lastIndex > 0)
|
||||
results.push(<br key={`line-delim-${index}`}/>);
|
||||
});
|
||||
|
@ -107,4 +107,6 @@ export const RegexPlayground = () => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export default RegexPlayground;
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
|
@ -147,4 +147,4 @@ function DeleteReleases() {
|
|||
);
|
||||
}
|
||||
|
||||
export default ReleaseSettings;
|
||||
export default ReleaseSettings;
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
export { default as APISettings } from "./Api";
|
||||
export { default as ApplicationSettings } from "./Application";
|
||||
export { default as DownloadClientSettings } from "./DownloadClient";
|
||||
export { default as FeedSettings } from "./Feed";
|
||||
export { default as IndexerSettings } from "./Indexer";
|
||||
export { default as IrcSettings } from "./Irc";
|
||||
export { default as LogSettings } from "./Logs";
|
||||
export { default as NotificationSettings } from "./Notifications";
|
||||
export { default as ReleaseSettings } from "./Releases";
|
||||
export { default as Api } from "./Api";
|
||||
export { default as Application } from "./Application";
|
||||
export { default as DownloadClient } from "./DownloadClient";
|
||||
export { default as Feed } from "./Feed";
|
||||
export { default as Indexer } from "./Indexer";
|
||||
export { default as Irc } from "./Irc";
|
||||
export { default as Logs } from "./Logs";
|
||||
export { default as Notification } from "./Notifications";
|
||||
export { default as Release } from "./Releases";
|
||||
export { default as RegexPlayground } from "./RegexPlayground";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue