mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
refactor(web): update to react 18 and other deps (#285)
* Started refactoring codebase for React 18. * Migrated to react-router v6 fully * Removed useless test setup along with relevant packages. * Removed leftover console.log statement * feat: use status forbidden for onboarding * refactor(web): use react hook form on login * fix: comply with r18 shenanigans * chore: update packages
This commit is contained in:
parent
d065fee16b
commit
4d753b76ed
24 changed files with 2252 additions and 2145 deletions
|
@ -133,7 +133,7 @@ func (h authHandler) canOnboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if userCount > 0 {
|
if userCount > 0 {
|
||||||
// send 503 service onboarding unavailable
|
// send 503 service onboarding unavailable
|
||||||
http.Error(w, "Onboarding unavailable", http.StatusServiceUnavailable)
|
http.Error(w, "Onboarding unavailable", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,34 +1,34 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^4.5.4",
|
"@fontsource/inter": "^4.5.10",
|
||||||
"@headlessui/react": "^1.2.0",
|
"@headlessui/react": "^1.6.3",
|
||||||
"@heroicons/react": "^1.0.1",
|
"@heroicons/react": "^1.0.6",
|
||||||
"date-fns": "^2.25.0",
|
"@hookform/error-message": "^2.0.0",
|
||||||
|
"date-fns": "^2.28.0",
|
||||||
"formik": "^2.2.9",
|
"formik": "^2.2.9",
|
||||||
"react": "^17.0.2",
|
"react": "^18.1.0",
|
||||||
"react-cookie": "^4.1.1",
|
"react-cookie": "^4.1.1",
|
||||||
"react-debounce-input": "^3.2.5",
|
"react-debounce-input": "^3.2.5",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^18.1.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-hot-toast": "^2.1.1",
|
"react-hook-form": "^7.31.3",
|
||||||
"react-multi-select-component": "4.2.5",
|
"react-hot-toast": "^2.2.0",
|
||||||
"react-query": "^3.18.1",
|
"react-multi-select-component": "^4.2.7",
|
||||||
|
"react-query": "^3.39.0",
|
||||||
"react-ridge-state": "4.2.2",
|
"react-ridge-state": "4.2.2",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-scripts": "^5.0.0",
|
"react-scripts": "^5.0.1",
|
||||||
"react-select": "5.0.0-beta.0",
|
"react-select": "^5.3.2",
|
||||||
"react-table": "^7.7.0",
|
"react-table": "^7.8.0",
|
||||||
"stacktracey": "^2.1.8",
|
"stacktracey": "^2.1.8"
|
||||||
"web-vitals": "^1.0.1"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "BROWSER=none react-scripts start",
|
"start": "BROWSER=none react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"lint": "eslint src/ --ext .js,.jsx,.ts,.tsx --color",
|
"lint": "eslint src/ --ext .js,.jsx,.ts,.tsx --color",
|
||||||
"lint:watch": "npm run lint -- --watch"
|
"lint:watch": "npm run lint -- --watch"
|
||||||
|
@ -45,32 +45,24 @@
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"@types/react": "17.0.0",
|
|
||||||
"@types/react-dom": "17.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.4.0",
|
"@tailwindcss/forms": "^0.5.2",
|
||||||
"@testing-library/jest-dom": "^5.11.4",
|
"@types/node": "^17.0.35",
|
||||||
"@testing-library/react": "^11.1.0",
|
"@types/react": "^18.0.9",
|
||||||
"@testing-library/user-event": "^12.1.10",
|
"@types/react-dom": "^18.0.5",
|
||||||
"@types/jest": "^26.0.15",
|
|
||||||
"@types/node": "^12.0.0",
|
|
||||||
"@types/react": "17.0.0",
|
|
||||||
"@types/react-dom": "17.0.0",
|
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"@types/react-table": "^7.7.7",
|
"@types/react-table": "^7.7.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
"@typescript-eslint/eslint-plugin": "^5.26.0",
|
||||||
"@typescript-eslint/parser": "^5.18.0",
|
"@typescript-eslint/parser": "^5.26.0",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.7",
|
||||||
"eslint": "^8.8.0",
|
"eslint": "^8.16.0",
|
||||||
"eslint-plugin-import": "^2.25.4",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-react": "^7.28.0",
|
"eslint-plugin-react": "^7.30.0",
|
||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"eslint-plugin-react-hooks": "^4.5.0",
|
||||||
"eslint-watch": "^8.0.0",
|
"eslint-watch": "^8.0.0",
|
||||||
"http-proxy-middleware": "^2.0.6",
|
"http-proxy-middleware": "^2.0.6",
|
||||||
"postcss": "^8.4.6",
|
"postcss": "^8.4.14",
|
||||||
"tailwindcss": "^3.0.18",
|
"tailwindcss": "^3.0.24",
|
||||||
"typescript": "^4.1.2"
|
"typescript": "^4.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,9 @@
|
||||||
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
|
|
||||||
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "react-query";
|
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "react-query";
|
||||||
import { ReactQueryDevtools } from "react-query/devtools";
|
import { ReactQueryDevtools } from "react-query/devtools";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { toast, Toaster } from "react-hot-toast";
|
import { toast, Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
import Base from "./screens/Base";
|
import { LocalRouter } from "./domain/routes";
|
||||||
import { Login } from "./screens/auth/login";
|
|
||||||
import { Logout } from "./screens/auth/logout";
|
|
||||||
import { Onboarding } from "./screens/auth/onboarding";
|
|
||||||
|
|
||||||
import { baseUrl } from "./utils";
|
|
||||||
import { AuthContext, SettingsContext } from "./utils/Context";
|
import { AuthContext, SettingsContext } from "./utils/Context";
|
||||||
import { ErrorPage } from "./components/alerts";
|
import { ErrorPage } from "./components/alerts";
|
||||||
import Toast from "./components/notifications/Toast";
|
import Toast from "./components/notifications/Toast";
|
||||||
|
@ -44,17 +38,7 @@ export function App() {
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
fallbackRender={ErrorPage}
|
fallbackRender={ErrorPage}
|
||||||
>
|
>
|
||||||
<Router basename={baseUrl()}>
|
<LocalRouter isLoggedIn={authContext.isLoggedIn} />
|
||||||
<Route exact path="/logout" component={Logout} />
|
|
||||||
{authContext.isLoggedIn ? (
|
|
||||||
<Route component={Base} />
|
|
||||||
) : (
|
|
||||||
<Switch>
|
|
||||||
<Route exact path="/onboard" component={Onboarding} />
|
|
||||||
<Route component={Login} />
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</Router>
|
|
||||||
{settings.debug ? (
|
{settings.debug ? (
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -10,7 +10,7 @@ const DEBUG: FC<DebugProps> = ({ values }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-2 flex flex-col mt-12 mb-12 bg-gray-100 dark:bg-gray-900">
|
<div className="w-full p-2 flex flex-col mt-6 bg-gray-100 dark:bg-gray-900">
|
||||||
<pre className="dark:text-gray-400">{JSON.stringify(values, null, 2)}</pre>
|
<pre className="dark:text-gray-400">{JSON.stringify(values, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,11 +6,13 @@ interface ErrorFieldProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrorField = ({ name, classNames }: ErrorFieldProps) => (
|
const ErrorField = ({ name, classNames }: ErrorFieldProps) => (
|
||||||
<Field name={name} subscribe={{ touched: true, error: true }}>
|
<div>
|
||||||
{({ meta: { touched, error } }: FieldProps) =>
|
<Field name={name} subscribe={{ touched: true, error: true }}>
|
||||||
touched && error ? <span className={classNames}>{error}</span> : null
|
{({ meta: { touched, error } }: FieldProps) =>
|
||||||
}
|
touched && error ? <span className={classNames}>{error}</span> : null
|
||||||
</Field>
|
}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface CheckboxFieldProps {
|
interface CheckboxFieldProps {
|
||||||
|
|
|
@ -161,7 +161,6 @@ export function DownloadClientSelect({
|
||||||
? clients.find((c) => c.id === field.value)?.name
|
? clients.find((c) => c.id === field.value)?.name
|
||||||
: "Choose a client"}
|
: "Choose a client"}
|
||||||
</span>
|
</span>
|
||||||
{/*<span className="block truncate">Choose a client</span>*/}
|
|
||||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
<SelectorIcon
|
<SelectorIcon
|
||||||
className="h-5 w-5 text-gray-400 dark:text-gray-300"
|
className="h-5 w-5 text-gray-400 dark:text-gray-300"
|
||||||
|
|
200
web/src/components/inputs/text.tsx
Normal file
200
web/src/components/inputs/text.tsx
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import React, {
|
||||||
|
FC,
|
||||||
|
forwardRef,
|
||||||
|
ReactNode
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
FieldError,
|
||||||
|
UseFormRegister,
|
||||||
|
Path,
|
||||||
|
RegisterOptions, DeepMap
|
||||||
|
} from "react-hook-form";
|
||||||
|
import { classNames, get } from "../../utils";
|
||||||
|
import { useToggle } from "../../hooks/hooks";
|
||||||
|
import { EyeIcon, EyeOffIcon } from "@heroicons/react/solid";
|
||||||
|
import { ErrorMessage } from "@hookform/error-message";
|
||||||
|
|
||||||
|
export type FormErrorMessageProps = {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormErrorMessage: FC<FormErrorMessageProps> = ({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}) => (
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
"mt-1 text-sm text-left block text-red-600",
|
||||||
|
className ?? ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type InputType = "text" | "email" | "password";
|
||||||
|
export type InputAutoComplete = "username" | "current-password";
|
||||||
|
export type InputColumnWidth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
|
||||||
|
export type InputProps = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: InputType;
|
||||||
|
className?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
autoComplete?: InputAutoComplete;
|
||||||
|
isHidden?: boolean;
|
||||||
|
columnWidth?: InputColumnWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Using maps so that the full Tailwind classes can be seen for purging
|
||||||
|
// see https://tailwindcss.com/docs/optimizing-for-production#writing-purgeable-html
|
||||||
|
|
||||||
|
// const sizeMap: { [key in InputSize]: string } = {
|
||||||
|
// medium: "p-3 text-base",
|
||||||
|
// large: "p-4 text-base"
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const Input: FC<InputProps> = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type ,
|
||||||
|
className = "",
|
||||||
|
placeholder,
|
||||||
|
autoComplete,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={ref}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
aria-label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type FormInputProps<TFormValues> = {
|
||||||
|
name: Path<TFormValues>;
|
||||||
|
rules?: RegisterOptions;
|
||||||
|
register?: UseFormRegister<TFormValues>;
|
||||||
|
errors?: Partial<DeepMap<TFormValues, FieldError>>;
|
||||||
|
} & Omit<InputProps, "name">;
|
||||||
|
|
||||||
|
export const TextInput = <TFormValues extends Record<string, unknown>>({
|
||||||
|
name,
|
||||||
|
register,
|
||||||
|
rules,
|
||||||
|
errors,
|
||||||
|
isHidden,
|
||||||
|
columnWidth,
|
||||||
|
...props
|
||||||
|
}: FormInputProps<TFormValues>): JSX.Element => {
|
||||||
|
// If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get
|
||||||
|
const errorMessages = get(errors, name);
|
||||||
|
const hasError = !!(errors && errorMessages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isHidden ? "hidden" : "",
|
||||||
|
columnWidth ? `col-span-${columnWidth}` : "col-span-12"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.label && (
|
||||||
|
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
{props.label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
name={name}
|
||||||
|
aria-invalid={hasError}
|
||||||
|
className={classNames(
|
||||||
|
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md",
|
||||||
|
hasError ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
{...(register && register(name, rules))}
|
||||||
|
/>
|
||||||
|
<ErrorMessage
|
||||||
|
errors={errors}
|
||||||
|
name={name as any}
|
||||||
|
render={({ message }) => (
|
||||||
|
<FormErrorMessage>{message}</FormErrorMessage>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasswordInput = <TFormValues extends Record<string, unknown>>({
|
||||||
|
name,
|
||||||
|
register,
|
||||||
|
rules,
|
||||||
|
errors,
|
||||||
|
isHidden,
|
||||||
|
columnWidth,
|
||||||
|
...props
|
||||||
|
}: FormInputProps<TFormValues>): JSX.Element => {
|
||||||
|
const [isVisible, toggleVisibility] = useToggle(false);
|
||||||
|
|
||||||
|
// If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get
|
||||||
|
const errorMessages = get(errors, name);
|
||||||
|
const hasError = !!(errors && errorMessages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
isHidden ? "hidden" : "",
|
||||||
|
columnWidth ? `col-span-${columnWidth}` : "col-span-12"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.label && (
|
||||||
|
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
{props.label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="sm:col-span-2 relative">
|
||||||
|
<Input
|
||||||
|
name={name}
|
||||||
|
aria-invalid={hasError}
|
||||||
|
type={isVisible ? "text" : "password"}
|
||||||
|
className={classNames(
|
||||||
|
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md",
|
||||||
|
hasError ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
{...(register && register(name, rules))}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 px-3 flex items-center" onClick={toggleVisibility}>
|
||||||
|
{!isVisible ? <EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-500" aria-hidden="true" /> : <EyeOffIcon className="h-5 w-5 text-gray-400 hover:text-gray-500" aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage
|
||||||
|
errors={errors}
|
||||||
|
name={name as any}
|
||||||
|
render={({ message }) => (
|
||||||
|
<FormErrorMessage>{message}</FormErrorMessage>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
55
web/src/domain/routes.tsx
Normal file
55
web/src/domain/routes.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Login } from "../screens/auth/login";
|
||||||
|
import { Logout } from "../screens/auth/logout";
|
||||||
|
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 ApplicationSettings from "../screens/settings/Application";
|
||||||
|
import DownloadClientSettings from "../screens/settings/DownloadClient";
|
||||||
|
import FeedSettings from "../screens/settings/Feed";
|
||||||
|
import IndexerSettings from "../screens/settings/Indexer";
|
||||||
|
import { IrcSettings } from "../screens/settings/Irc";
|
||||||
|
import NotificationSettings from "../screens/settings/Notifications";
|
||||||
|
import { RegexPlayground } from "../screens/settings/RegexPlayground";
|
||||||
|
import ReleaseSettings from "../screens/settings/Releases";
|
||||||
|
|
||||||
|
import { baseUrl } from "../utils";
|
||||||
|
|
||||||
|
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
||||||
|
<BrowserRouter basename={baseUrl()}>
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/logout" element={<Logout />} />
|
||||||
|
<Route element={<Base />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="logs" element={<Logs />} />
|
||||||
|
<Route path="releases" element={<Releases />} />
|
||||||
|
<Route path="filters">
|
||||||
|
<Route index element={<Filters />} />
|
||||||
|
<Route path=":filterId/*" element={<FilterDetails />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="settings" element={<Settings />}>
|
||||||
|
<Route index element={<ApplicationSettings />} />
|
||||||
|
<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>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
) : (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/onboard" element={<Onboarding />} />
|
||||||
|
<Route path="*" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
)}
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
|
@ -102,7 +102,7 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
|
className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Field name="name">
|
<Field name="name">
|
||||||
|
@ -119,7 +119,7 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{meta.touched && meta.error &&
|
{meta.touched && meta.error &&
|
||||||
<span className="block mt-2 text-red-500">{meta.error}</span>}
|
<span className="block mt-2 text-red-500">{meta.error}</span>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -136,13 +136,13 @@ function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
||||||
className="bg-white dark:bg-gray-800 py-2 px-4 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
className="bg-white dark:bg-gray-800 py-2 px-4 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import "@fontsource/inter/variable.css";
|
import "@fontsource/inter/variable.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
@ -16,9 +16,10 @@ window.APP = window.APP || {};
|
||||||
// Initializes auth and theme contexts
|
// Initializes auth and theme contexts
|
||||||
InitializeGlobalContext();
|
InitializeGlobalContext();
|
||||||
|
|
||||||
ReactDOM.render(
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
document.getElementById("root")
|
|
||||||
);
|
);
|
|
@ -1,15 +0,0 @@
|
||||||
import type { ReportHandler } from "web-vitals";
|
|
||||||
|
|
||||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
|
||||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
|
||||||
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
|
||||||
getCLS(onPerfEntry);
|
|
||||||
getFID(onPerfEntry);
|
|
||||||
getFCP(onPerfEntry);
|
|
||||||
getLCP(onPerfEntry);
|
|
||||||
getTTFB(onPerfEntry);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default reportWebVitals;
|
|
|
@ -1,16 +1,9 @@
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { match } from "react-router-dom";
|
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||||
import { Link, NavLink, Route, Switch } from "react-router-dom";
|
|
||||||
import { Disclosure, Menu, Transition } from "@headlessui/react";
|
import { Disclosure, Menu, Transition } from "@headlessui/react";
|
||||||
import { ExternalLinkIcon } from "@heroicons/react/solid";
|
import { ExternalLinkIcon } from "@heroicons/react/solid";
|
||||||
import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline";
|
import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline";
|
||||||
|
|
||||||
import Settings from "./Settings";
|
|
||||||
|
|
||||||
import { Logs } from "./Logs";
|
|
||||||
import { Releases } from "./releases";
|
|
||||||
import { Dashboard } from "./dashboard";
|
|
||||||
import { FilterDetails, Filters } from "./filters";
|
|
||||||
import { AuthContext } from "../utils/Context";
|
import { AuthContext } from "../utils/Context";
|
||||||
|
|
||||||
import logo from "../logo.png";
|
import logo from "../logo.png";
|
||||||
|
@ -24,23 +17,6 @@ function classNames(...classes: string[]) {
|
||||||
return classes.filter(Boolean).join(" ");
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveMatcher = (
|
|
||||||
match: match | null,
|
|
||||||
location: { pathname: string },
|
|
||||||
item: NavItem
|
|
||||||
) => {
|
|
||||||
if (!match)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (match?.url === "/" && item.path === "/" && location.pathname === "/")
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (match.url === "/")
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Base() {
|
export default function Base() {
|
||||||
const authContext = AuthContext.useValue();
|
const authContext = AuthContext.useValue();
|
||||||
const nav: Array<NavItem> = [
|
const nav: Array<NavItem> = [
|
||||||
|
@ -81,13 +57,11 @@ export default function Base() {
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.name + itemIdx}
|
key={item.name + itemIdx}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
strict
|
className={({ isActive }) => classNames(
|
||||||
className={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",
|
||||||
"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",
|
||||||
"transition-colors duration-200"
|
isActive ? "text-black dark:text-gray-50 font-bold" : "text-gray-600 dark:text-gray-500"
|
||||||
)}
|
)}
|
||||||
activeClassName="text-black dark:text-gray-50 !font-bold"
|
|
||||||
isActive={(match, location) => isActiveMatcher(match, location, item)}
|
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
@ -199,10 +173,11 @@ export default function Base() {
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
strict
|
className={({ isActive }) => classNames(
|
||||||
className="dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base font-medium"
|
// TODO: Double check whether this is correct
|
||||||
activeClassName="font-bold bg-gray-300 text-black"
|
"dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
|
||||||
isActive={(match, location) => isActiveMatcher(match, location, item)}
|
isActive ? "font-bold bg-gray-300 text-black" : "font-medium"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
@ -219,32 +194,7 @@ export default function Base() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
<Outlet />
|
||||||
<Switch>
|
|
||||||
<Route path="/logs">
|
|
||||||
<Logs/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/settings">
|
|
||||||
<Settings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/releases">
|
|
||||||
<Releases/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route exact={true} path="/filters">
|
|
||||||
<Filters/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/filters/:filterId">
|
|
||||||
<FilterDetails/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route exact path="/">
|
|
||||||
<Dashboard/>
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,56 +1,52 @@
|
||||||
import { BellIcon, ChatAlt2Icon, CogIcon, CollectionIcon, DownloadIcon, KeyIcon, RssIcon } from "@heroicons/react/outline";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch } from "react-router-dom";
|
import {
|
||||||
|
BellIcon,
|
||||||
|
ChatAlt2Icon,
|
||||||
|
CogIcon,
|
||||||
|
CollectionIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
KeyIcon,
|
||||||
|
RssIcon
|
||||||
|
} from "@heroicons/react/outline";
|
||||||
|
|
||||||
import { classNames } from "../utils";
|
import { classNames } from "../utils";
|
||||||
import IndexerSettings from "./settings/Indexer";
|
|
||||||
import { IrcSettings } from "./settings/Irc";
|
|
||||||
import ApplicationSettings from "./settings/Application";
|
|
||||||
import DownloadClientSettings from "./settings/DownloadClient";
|
|
||||||
import { RegexPlayground } from "./settings/RegexPlayground";
|
|
||||||
import ReleaseSettings from "./settings/Releases";
|
|
||||||
import NotificationSettings from "./settings/Notifications";
|
|
||||||
import FeedSettings from "./settings/Feed";
|
|
||||||
|
|
||||||
interface NavTabType {
|
interface NavTabType {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: typeof CogIcon;
|
icon: typeof CogIcon;
|
||||||
current: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const subNavigation: NavTabType[] = [
|
const subNavigation: NavTabType[] = [
|
||||||
{ name: "Application", href: "", icon: CogIcon, current: true },
|
{ name: "Application", href: "", icon: CogIcon },
|
||||||
{ name: "Indexers", href: "indexers", icon: KeyIcon, current: false },
|
{ name: "Indexers", href: "indexers", icon: KeyIcon },
|
||||||
{ name: "IRC", href: "irc", icon: ChatAlt2Icon, current: false },
|
{ name: "IRC", href: "irc", icon: ChatAlt2Icon },
|
||||||
{ name: "Feeds", href: "feeds", icon: RssIcon, current: false },
|
{ name: "Feeds", href: "feeds", icon: RssIcon },
|
||||||
{ name: "Clients", href: "clients", icon: DownloadIcon, current: false },
|
{ name: "Clients", href: "clients", icon: DownloadIcon },
|
||||||
{ name: "Notifications", href: "notifications", icon: BellIcon, current: false },
|
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
||||||
{ name: "Releases", href: "releases", icon: CollectionIcon, current: false }
|
{ name: "Releases", href: "releases", icon: CollectionIcon }
|
||||||
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
||||||
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
|
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface NavLinkProps {
|
interface NavLinkProps {
|
||||||
item: NavTabType;
|
item: NavTabType;
|
||||||
url: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubNavLink({ item, url }: NavLinkProps) {
|
function SubNavLink({ item }: NavLinkProps) {
|
||||||
const location = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { pathname } = location;
|
|
||||||
|
|
||||||
const splitLocation = pathname.split("/");
|
const splitLocation = pathname.split("/");
|
||||||
|
|
||||||
// we need to clean the / if it's a base root path
|
// we need to clean the / if it's a base root path
|
||||||
const too = item.href ? `${url}/${item.href}` : url;
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.name}
|
key={item.name}
|
||||||
to={too}
|
to={item.href}
|
||||||
exact={true}
|
end
|
||||||
activeClassName="bg-teal-50 dark:bg-gray-700 border-teal-500 dark:border-blue-500 text-teal-700 dark:text-white hover:bg-teal-50 dark:hover:bg-gray-500 hover:text-teal-700 dark:hover:text-gray-200"
|
className={({ isActive }) => classNames(
|
||||||
className={classNames(
|
"border-transparent text-gray-900 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-300 group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
|
||||||
"border-transparent text-gray-900 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-300 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"
|
isActive ?
|
||||||
|
"bg-teal-50 dark:bg-gray-700 border-teal-500 dark:border-blue-500 text-teal-700 dark:text-white hover:bg-teal-50 dark:hover:bg-gray-500 hover:text-teal-700 dark:hover:text-gray-200" : ""
|
||||||
)}
|
)}
|
||||||
aria-current={splitLocation[2] === item.href ? "page" : undefined}
|
aria-current={splitLocation[2] === item.href ? "page" : undefined}
|
||||||
>
|
>
|
||||||
|
@ -65,15 +61,14 @@ function SubNavLink({ item, url }: NavLinkProps) {
|
||||||
|
|
||||||
interface SidebarNavProps {
|
interface SidebarNavProps {
|
||||||
subNavigation: NavTabType[];
|
subNavigation: NavTabType[];
|
||||||
url: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarNav({ subNavigation, url }: SidebarNavProps) {
|
function SidebarNav({ subNavigation }: SidebarNavProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="py-2 lg:col-span-3">
|
<aside className="py-2 lg:col-span-3">
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{subNavigation.map((item) => (
|
{subNavigation.map((item) => (
|
||||||
<SubNavLink item={item} url={url} key={item.href}/>
|
<SubNavLink item={item} key={item.href}/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
@ -81,7 +76,6 @@ function SidebarNav({ subNavigation, url }: SidebarNavProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { url } = useRouteMatch();
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<header className="py-10">
|
<header className="py-10">
|
||||||
|
@ -93,41 +87,8 @@ export default function Settings() {
|
||||||
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
|
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
|
||||||
<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 url={url} subNavigation={subNavigation}/>
|
<SidebarNav subNavigation={subNavigation}/>
|
||||||
|
<Outlet />
|
||||||
<RouteSwitch>
|
|
||||||
<Route exact path={url}>
|
|
||||||
<ApplicationSettings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/indexers`}>
|
|
||||||
<IndexerSettings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/feeds`}>
|
|
||||||
<FeedSettings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/irc`}>
|
|
||||||
<IrcSettings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/clients`}>
|
|
||||||
<DownloadClientSettings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/notifications`}>
|
|
||||||
<NotificationSettings />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/releases`}>
|
|
||||||
<ReleaseSettings/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/regex-playground`}>
|
|
||||||
<RegexPlayground />
|
|
||||||
</Route>
|
|
||||||
</RouteSwitch>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,44 +1,48 @@
|
||||||
import { useHistory } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { Form, Formik } from "formik";
|
|
||||||
|
|
||||||
import { APIClient } from "../../api/APIClient";
|
import { APIClient } from "../../api/APIClient";
|
||||||
import { TextField, PasswordField } from "../../components/inputs";
|
|
||||||
|
|
||||||
import logo from "../../logo.png";
|
import logo from "../../logo.png";
|
||||||
import { AuthContext } from "../../utils/Context";
|
import { AuthContext } from "../../utils/Context";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
|
import { PasswordInput, TextInput } from "../../components/inputs/text";
|
||||||
|
|
||||||
interface LoginData {
|
export type LoginFormFields = {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
const history = useHistory();
|
const { handleSubmit, register, formState: { errors } } = useForm<LoginFormFields>({
|
||||||
|
defaultValues: { username: "", password: "" },
|
||||||
|
mode: "onBlur"
|
||||||
|
});
|
||||||
|
const navigate = useNavigate();
|
||||||
const [, setAuthContext] = AuthContext.use();
|
const [, setAuthContext] = AuthContext.use();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if onboarding is available for this instance
|
// Check if onboarding is available for this instance
|
||||||
// and redirect if needed
|
// and redirect if needed
|
||||||
APIClient.auth.canOnboard()
|
APIClient.auth.canOnboard()
|
||||||
.then(() => history.push("/onboard"));
|
.then(() => navigate("/onboard"));
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
const mutation = useMutation(
|
const loginMutation = useMutation(
|
||||||
(data: LoginData) => APIClient.auth.login(data.username, data.password),
|
(data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
|
||||||
{
|
{
|
||||||
onSuccess: (_, variables: LoginData) => {
|
onSuccess: (_, variables: LoginFormFields) => {
|
||||||
setAuthContext({
|
setAuthContext({
|
||||||
username: variables.username,
|
username: variables.username,
|
||||||
isLoggedIn: true
|
isLoggedIn: true
|
||||||
});
|
});
|
||||||
history.push("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = (data: LoginData) => mutation.mutate(data);
|
const onSubmit: SubmitHandler<LoginFormFields> = (data: LoginFormFields) => loginMutation.mutate(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
@ -52,25 +56,39 @@ export const Login = () => {
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg">
|
||||||
<div className="bg-white dark:bg-gray-800 py-8 px-4 sm:rounded-lg sm:px-10">
|
<div className="bg-white dark:bg-gray-800 py-8 px-4 sm:rounded-lg sm:px-10">
|
||||||
|
|
||||||
<Formik
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
initialValues={{ username: "", password: "" }}
|
<div className="space-y-6">
|
||||||
onSubmit={handleSubmit}
|
<TextInput<LoginFormFields>
|
||||||
>
|
name="username"
|
||||||
<Form>
|
id="username"
|
||||||
<div className="space-y-6">
|
label="username"
|
||||||
<TextField name="username" label="Username" columns={6} autoComplete="username" />
|
type="text"
|
||||||
<PasswordField name="password" label="Password" columns={6} autoComplete="current-password" />
|
register={register}
|
||||||
</div>
|
rules={{ required: "Username is required" }}
|
||||||
<div className="mt-6">
|
errors={errors}
|
||||||
<button
|
autoComplete="username"
|
||||||
type="submit"
|
/>
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
<PasswordInput<LoginFormFields>
|
||||||
>
|
name="password"
|
||||||
Sign in
|
id="password"
|
||||||
</button>
|
label="password"
|
||||||
</div>
|
register={register}
|
||||||
</Form>
|
rules={{ required: "Password is required" }}
|
||||||
</Formik>
|
errors={errors}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useCookies } from "react-cookie";
|
import { useCookies } from "react-cookie";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { APIClient } from "../../api/APIClient";
|
import { APIClient } from "../../api/APIClient";
|
||||||
import { AuthContext } from "../../utils/Context";
|
import { AuthContext } from "../../utils/Context";
|
||||||
|
|
||||||
export const Logout = () => {
|
export const Logout = () => {
|
||||||
const history = useHistory();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [, setAuthContext] = AuthContext.use();
|
const [, setAuthContext] = AuthContext.use();
|
||||||
const [,, removeCookie] = useCookies(["user_session"]);
|
const [,, removeCookie] = useCookies(["user_session"]);
|
||||||
|
@ -15,10 +15,10 @@ export const Logout = () => {
|
||||||
() => {
|
() => {
|
||||||
APIClient.auth.logout()
|
APIClient.auth.logout()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setAuthContext({ username: "", isLoggedIn: false });
|
|
||||||
removeCookie("user_session");
|
removeCookie("user_session");
|
||||||
|
setAuthContext({ username: "", isLoggedIn: false });
|
||||||
|
|
||||||
history.push("/login");
|
navigate("/login");
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[history, removeCookie, setAuthContext]
|
[history, removeCookie, setAuthContext]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Form, Formik } from "formik";
|
import { Form, Formik } from "formik";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { APIClient } from "../../api/APIClient";
|
import { APIClient } from "../../api/APIClient";
|
||||||
|
|
||||||
import { TextField, PasswordField } from "../../components/inputs";
|
import { TextField, PasswordField } from "../../components/inputs";
|
||||||
|
@ -30,15 +30,11 @@ export const Onboarding = () => {
|
||||||
return obj;
|
return obj;
|
||||||
};
|
};
|
||||||
|
|
||||||
const history = useHistory();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
(data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
|
(data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
|
||||||
{
|
{ onSuccess: () => navigate("/login") }
|
||||||
onSuccess: () => {
|
|
||||||
history.push("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { EmptyListState } from "../../components/emptystates";
|
||||||
|
|
||||||
import * as Icons from "../../components/Icons";
|
import * as Icons from "../../components/Icons";
|
||||||
import * as DataTable from "../../components/data-table";
|
import * as DataTable from "../../components/data-table";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
// This is a custom filter UI for selecting
|
// This is a custom filter UI for selecting
|
||||||
// a unique option from a list
|
// a unique option from a list
|
||||||
|
@ -32,7 +33,7 @@ function SelectColumnFilter({
|
||||||
// Render a multi-select box
|
// Render a multi-select box
|
||||||
return (
|
return (
|
||||||
<label className="flex items-baseline gap-x-2">
|
<label className="flex items-baseline gap-x-2">
|
||||||
<span className="text-gray-700">{render("Header")}: </span>
|
<span className="text-gray-700"><>{render("Header")}:</></span>
|
||||||
<select
|
<select
|
||||||
className="border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
className="border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||||
name={id}
|
name={id}
|
||||||
|
@ -99,7 +100,7 @@ function Table({ columns, data }: TableProps) {
|
||||||
{...columnRest}
|
{...columnRest}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{column.render("Header")}
|
<>{column.render("Header")}</>
|
||||||
{/* Add a sort direction indicator */}
|
{/* Add a sort direction indicator */}
|
||||||
<span>
|
<span>
|
||||||
{column.isSorted ? (
|
{column.isSorted ? (
|
||||||
|
@ -138,7 +139,7 @@ function Table({ columns, data }: TableProps) {
|
||||||
role="cell"
|
role="cell"
|
||||||
{...cellRowRest}
|
{...cellRowRest}
|
||||||
>
|
>
|
||||||
{cell.render("Cell")}
|
<>{cell.render("Cell")}</>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -3,11 +3,10 @@ import { useMutation, useQuery } from "react-query";
|
||||||
import {
|
import {
|
||||||
NavLink,
|
NavLink,
|
||||||
Route,
|
Route,
|
||||||
Switch as RouteSwitch,
|
Routes,
|
||||||
useHistory,
|
|
||||||
useLocation,
|
useLocation,
|
||||||
useParams,
|
useNavigate,
|
||||||
useRouteMatch
|
useParams
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { Field, FieldArray, FieldProps, Form, Formik, FormikValues } from "formik";
|
import { Field, FieldArray, FieldProps, Form, Formik, FormikValues } from "formik";
|
||||||
|
@ -55,24 +54,21 @@ import { EmptyListState } from "../../components/emptystates";
|
||||||
interface tabType {
|
interface tabType {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
current: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: tabType[] = [
|
const tabs: tabType[] = [
|
||||||
{ name: "General", href: "", current: true },
|
{ name: "General", href: "" },
|
||||||
{ name: "Movies and TV", href: "movies-tv", current: false },
|
{ name: "Movies and TV", href: "movies-tv" },
|
||||||
{ name: "Music", href: "music", current: false },
|
{ name: "Music", href: "music" },
|
||||||
// { name: 'P2P', href: 'p2p', current: false },
|
{ name: "Advanced", href: "advanced" },
|
||||||
{ name: "Advanced", href: "advanced", current: false },
|
{ name: "Actions", href: "actions" }
|
||||||
{ name: "Actions", href: "actions", current: false }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface NavLinkProps {
|
export interface NavLinkProps {
|
||||||
item: tabType;
|
item: tabType;
|
||||||
url: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabNavLink({ item, url }: NavLinkProps) {
|
function TabNavLink({ item }: NavLinkProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const splitLocation = location.pathname.split("/");
|
const splitLocation = location.pathname.split("/");
|
||||||
|
|
||||||
|
@ -80,11 +76,11 @@ function TabNavLink({ item, url }: NavLinkProps) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href ? `${url}/${item.href}` : url}
|
to={item.href}
|
||||||
exact
|
end
|
||||||
activeClassName="border-purple-600 dark:border-blue-500 text-purple-600 dark:text-white"
|
className={({ isActive }) => classNames(
|
||||||
className={classNames(
|
"text-gray-500 hover:text-purple-600 dark:hover:text-white hover:border-purple-600 dark:hover:border-blue-500 whitespace-nowrap py-4 px-1 font-medium text-sm",
|
||||||
"border-transparent text-gray-500 hover:text-purple-600 dark:hover:text-white hover:border-purple-600 dark:hover:border-blue-500 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
|
isActive ? "border-b-2 border-purple-600 dark:border-blue-500 text-purple-600 dark:text-white" : ""
|
||||||
)}
|
)}
|
||||||
aria-current={splitLocation[2] === item.href ? "page" : undefined}
|
aria-current={splitLocation[2] === item.href ? "page" : undefined}
|
||||||
>
|
>
|
||||||
|
@ -132,13 +128,13 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
|
||||||
className="light:bg-white light:border light:border-gray-300 rounded-md 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="light:bg-white light:border light:border-gray-300 rounded-md 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"
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ml-4 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
className="ml-4 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -147,17 +143,16 @@ const FormButtonsGroup = ({ values, deleteAction, reset }: FormButtonsGroupProps
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FilterDetails() {
|
export default function FilterDetails() {
|
||||||
const history = useHistory();
|
const navigate = useNavigate();
|
||||||
const { url } = useRouteMatch();
|
|
||||||
const { filterId } = useParams<{ filterId: string }>();
|
const { filterId } = useParams<{ filterId: string }>();
|
||||||
|
|
||||||
const { isLoading, data: filter } = useQuery(
|
const { isLoading, data: filter } = useQuery(
|
||||||
["filters", filterId],
|
["filters", filterId],
|
||||||
() => APIClient.filters.getByID(parseInt(filterId)),
|
() => APIClient.filters.getByID(parseInt(filterId ?? "0")),
|
||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
onError: () => history.push("./")
|
onError: () => navigate("./")
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -184,7 +179,7 @@ export default function FilterDetails() {
|
||||||
queryClient.invalidateQueries(["filters"]);
|
queryClient.invalidateQueries(["filters"]);
|
||||||
|
|
||||||
// redirect
|
// redirect
|
||||||
history.push("/filters");
|
navigate("/filters");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -218,8 +213,8 @@ export default function FilterDetails() {
|
||||||
<header className="py-10">
|
<header className="py-10">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
|
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
|
||||||
<h1 className="text-3xl font-bold text-black dark:text-white">
|
<h1 className="text-3xl font-bold text-black dark:text-white">
|
||||||
<NavLink to="/filters" exact>
|
<NavLink to="/filters">
|
||||||
Filters
|
Filters
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</h1>
|
</h1>
|
||||||
<ChevronRightIcon className="h-6 w-6 text-gray-500" aria-hidden="true" />
|
<ChevronRightIcon className="h-6 w-6 text-gray-500" aria-hidden="true" />
|
||||||
|
@ -229,102 +224,82 @@ export default function FilterDetails() {
|
||||||
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
|
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div className="relative mx-auto md:px-6 xl:px-4">
|
<div className="relative mx-auto md:px-6 xl:px-4">
|
||||||
<div className="px-4 sm:px-6 md:px-0">
|
<div className="pt-1 pb-6 block overflow-auto">
|
||||||
<div className="pt-1 pb-6">
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="block overflow-auto">
|
<nav className="-mb-px flex space-x-6 sm:space-x-8">
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
{tabs.map((tab) => (
|
||||||
<nav className="-mb-px flex space-x-6 sm:space-x-8">
|
<TabNavLink item={tab} key={tab.href} />
|
||||||
{tabs.map((tab) => (
|
))}
|
||||||
<TabNavLink item={tab} url={url} key={tab.href} />
|
</nav>
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
id: filter.id,
|
|
||||||
name: filter.name,
|
|
||||||
enabled: filter.enabled || false,
|
|
||||||
min_size: filter.min_size,
|
|
||||||
max_size: filter.max_size,
|
|
||||||
delay: filter.delay,
|
|
||||||
priority: filter.priority,
|
|
||||||
max_downloads: filter.max_downloads,
|
|
||||||
max_downloads_unit: filter.max_downloads_unit,
|
|
||||||
use_regex: filter.use_regex || false,
|
|
||||||
shows: filter.shows,
|
|
||||||
years: filter.years,
|
|
||||||
resolutions: filter.resolutions || [],
|
|
||||||
sources: filter.sources || [],
|
|
||||||
codecs: filter.codecs || [],
|
|
||||||
containers: filter.containers || [],
|
|
||||||
match_hdr: filter.match_hdr || [],
|
|
||||||
except_hdr: filter.except_hdr || [],
|
|
||||||
match_other: filter.match_other || [],
|
|
||||||
except_other: filter.except_other || [],
|
|
||||||
seasons: filter.seasons,
|
|
||||||
episodes: filter.episodes,
|
|
||||||
match_releases: filter.match_releases,
|
|
||||||
except_releases: filter.except_releases,
|
|
||||||
match_release_groups: filter.match_release_groups,
|
|
||||||
except_release_groups: filter.except_release_groups,
|
|
||||||
match_categories: filter.match_categories,
|
|
||||||
except_categories: filter.except_categories,
|
|
||||||
tags: filter.tags,
|
|
||||||
except_tags: filter.except_tags,
|
|
||||||
match_uploaders: filter.match_uploaders,
|
|
||||||
except_uploaders: filter.except_uploaders,
|
|
||||||
freeleech: filter.freeleech,
|
|
||||||
freeleech_percent: filter.freeleech_percent,
|
|
||||||
formats: filter.formats || [],
|
|
||||||
quality: filter.quality || [],
|
|
||||||
media: filter.media || [],
|
|
||||||
match_release_types: filter.match_release_types || [],
|
|
||||||
log_score: filter.log_score,
|
|
||||||
log: filter.log,
|
|
||||||
cue: filter.cue,
|
|
||||||
perfect_flac: filter.perfect_flac,
|
|
||||||
artists: filter.artists,
|
|
||||||
albums: filter.albums,
|
|
||||||
origins: filter.origins || [],
|
|
||||||
indexers: filter.indexers || [],
|
|
||||||
actions: filter.actions || []
|
|
||||||
} as Filter}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
{({ values, dirty, resetForm }) => (
|
|
||||||
<Form>
|
|
||||||
<RouteSwitch>
|
|
||||||
<Route exact path={url}>
|
|
||||||
<General />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/movies-tv`}>
|
|
||||||
<MoviesTv />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/music`}>
|
|
||||||
<Music />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/advanced`}>
|
|
||||||
<Advanced />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path={`${url}/actions`}>
|
|
||||||
<FilterActions filter={filter} values={values} />
|
|
||||||
</Route>
|
|
||||||
</RouteSwitch>
|
|
||||||
|
|
||||||
<FormButtonsGroup values={values} deleteAction={deleteAction} dirty={dirty} reset={resetForm} />
|
|
||||||
|
|
||||||
<DEBUG values={values} />
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Formik>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
id: filter.id,
|
||||||
|
name: filter.name,
|
||||||
|
enabled: filter.enabled || false,
|
||||||
|
min_size: filter.min_size,
|
||||||
|
max_size: filter.max_size,
|
||||||
|
delay: filter.delay,
|
||||||
|
priority: filter.priority,
|
||||||
|
max_downloads: filter.max_downloads,
|
||||||
|
max_downloads_unit: filter.max_downloads_unit,
|
||||||
|
use_regex: filter.use_regex || false,
|
||||||
|
shows: filter.shows,
|
||||||
|
years: filter.years,
|
||||||
|
resolutions: filter.resolutions || [],
|
||||||
|
sources: filter.sources || [],
|
||||||
|
codecs: filter.codecs || [],
|
||||||
|
containers: filter.containers || [],
|
||||||
|
match_hdr: filter.match_hdr || [],
|
||||||
|
except_hdr: filter.except_hdr || [],
|
||||||
|
match_other: filter.match_other || [],
|
||||||
|
except_other: filter.except_other || [],
|
||||||
|
seasons: filter.seasons,
|
||||||
|
episodes: filter.episodes,
|
||||||
|
match_releases: filter.match_releases,
|
||||||
|
except_releases: filter.except_releases,
|
||||||
|
match_release_groups: filter.match_release_groups,
|
||||||
|
except_release_groups: filter.except_release_groups,
|
||||||
|
match_categories: filter.match_categories,
|
||||||
|
except_categories: filter.except_categories,
|
||||||
|
tags: filter.tags,
|
||||||
|
except_tags: filter.except_tags,
|
||||||
|
match_uploaders: filter.match_uploaders,
|
||||||
|
except_uploaders: filter.except_uploaders,
|
||||||
|
freeleech: filter.freeleech,
|
||||||
|
freeleech_percent: filter.freeleech_percent,
|
||||||
|
formats: filter.formats || [],
|
||||||
|
quality: filter.quality || [],
|
||||||
|
media: filter.media || [],
|
||||||
|
match_release_types: filter.match_release_types || [],
|
||||||
|
log_score: filter.log_score,
|
||||||
|
log: filter.log,
|
||||||
|
cue: filter.cue,
|
||||||
|
perfect_flac: filter.perfect_flac,
|
||||||
|
artists: filter.artists,
|
||||||
|
albums: filter.albums,
|
||||||
|
origins: filter.origins || [],
|
||||||
|
indexers: filter.indexers || [],
|
||||||
|
actions: filter.actions || []
|
||||||
|
} as Filter}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{({ values, dirty, resetForm }) => (
|
||||||
|
<Form>
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<General />} />
|
||||||
|
<Route path="movies-tv" element={<MoviesTv />} />
|
||||||
|
<Route path="music" element={<Music />} />
|
||||||
|
<Route path="advanced" element={<Advanced />} />
|
||||||
|
<Route path="actions" element={<FilterActions filter={filter} values={values} />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
<FormButtonsGroup values={values} deleteAction={deleteAction} dirty={dirty} reset={resetForm} />
|
||||||
|
<DEBUG values={values} />
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -333,7 +308,7 @@ export default function FilterDetails() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function General() {
|
export function General() {
|
||||||
const { isLoading, data: indexers } = useQuery(
|
const { isLoading, data: indexers } = useQuery(
|
||||||
["filters", "indexer_list"],
|
["filters", "indexer_list"],
|
||||||
APIClient.indexers.getOptions,
|
APIClient.indexers.getOptions,
|
||||||
|
@ -380,7 +355,7 @@ function General() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoviesTv() {
|
export function MoviesTv() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
|
@ -424,7 +399,7 @@ function MoviesTv() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Music() {
|
export function Music() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
|
@ -476,7 +451,7 @@ function Music() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Advanced() {
|
export function Advanced() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CollapsableSection title="Releases" subtitle="Match only certain release names and/or ignore other release names">
|
<CollapsableSection title="Releases" subtitle="Match only certain release names and/or ignore other release names">
|
||||||
|
@ -559,7 +534,7 @@ interface FilterActionsProps {
|
||||||
values: FormikValues;
|
values: FormikValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterActions({ filter, values }: FilterActionsProps) {
|
export function FilterActions({ filter, values }: FilterActionsProps) {
|
||||||
const { data } = useQuery(
|
const { data } = useQuery(
|
||||||
["filters", "download_clients"],
|
["filters", "download_clients"],
|
||||||
APIClient.download_clients.getAll,
|
APIClient.download_clients.getAll,
|
||||||
|
@ -973,7 +948,7 @@ function FilterActionsItem({ action, clients, idx, remove }: FilterActionsItemPr
|
||||||
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 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"
|
||||||
onClick={toggleDeleteModal}
|
onClick={toggleDeleteModal}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -982,7 +957,7 @@ function FilterActionsItem({ action, clients, idx, remove }: FilterActionsItemPr
|
||||||
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="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"
|
||||||
onClick={toggleEdit}
|
onClick={toggleEdit}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -41,7 +41,7 @@ export default function Filters() {
|
||||||
<header className="py-10">
|
<header className="py-10">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
|
<div className="max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
|
||||||
<h1 className="text-3xl font-bold text-black dark:text-white">
|
<h1 className="text-3xl font-bold text-black dark:text-white">
|
||||||
Filters
|
Filters
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
|
@ -49,7 +49,7 @@ export default function Filters() {
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||||
onClick={toggleCreateFilter}
|
onClick={toggleCreateFilter}
|
||||||
>
|
>
|
||||||
Add new
|
Add new
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -101,8 +101,8 @@ function FilterList({ filters }: FilterListProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterItemDropdownProps {
|
interface FilterItemDropdownProps {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
onToggle: (newState: boolean) => void;
|
onToggle: (newState: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilterItemDropdown = ({
|
const FilterItemDropdown = ({
|
||||||
|
@ -172,7 +172,7 @@ const FilterItemDropdown = ({
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<Link
|
<Link
|
||||||
to={`filters/${filter.id.toString()}`}
|
to={filter.id.toString()}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
|
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
|
||||||
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
|
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
|
||||||
|
@ -185,7 +185,7 @@ const FilterItemDropdown = ({
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
@ -205,7 +205,7 @@ const FilterItemDropdown = ({
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
Toggle
|
Toggle
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
@ -225,7 +225,7 @@ const FilterItemDropdown = ({
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
@ -247,7 +247,7 @@ const FilterItemDropdown = ({
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
@ -319,7 +319,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
<td className="px-6 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
<Link
|
<Link
|
||||||
to={`filters/${filter.id.toString()}`}
|
to={filter.id.toString()}
|
||||||
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
|
className="hover:text-black dark:hover:text-gray-300 w-full py-4 flex"
|
||||||
>
|
>
|
||||||
{filter.name}
|
{filter.name}
|
||||||
|
|
|
@ -186,7 +186,7 @@ export const ReleaseTable = () => {
|
||||||
headerGroup.headers.map((column) => (
|
headerGroup.headers.map((column) => (
|
||||||
column.Filter ? (
|
column.Filter ? (
|
||||||
<div className="mt-2 sm:mt-0" key={column.id}>
|
<div className="mt-2 sm:mt-0" key={column.id}>
|
||||||
{column.render("Filter")}
|
<>{column.render("Filter")}</>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
))
|
))
|
||||||
|
@ -211,7 +211,7 @@ export const ReleaseTable = () => {
|
||||||
{...columnRest}
|
{...columnRest}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{column.render("Header")}
|
<>{column.render("Header")}</>
|
||||||
{/* Add a sort direction indicator */}
|
{/* Add a sort direction indicator */}
|
||||||
<span>
|
<span>
|
||||||
{column.isSorted ? (
|
{column.isSorted ? (
|
||||||
|
@ -251,7 +251,7 @@ export const ReleaseTable = () => {
|
||||||
role="cell"
|
role="cell"
|
||||||
{...cellRowRest}
|
{...cellRowRest}
|
||||||
>
|
>
|
||||||
{cell.render("Cell")}
|
<>{cell.render("Cell")}</>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -103,10 +103,10 @@ const ListItem = ({ idx, network }: ListItemProps) => {
|
||||||
<div className="col-span-4 flex justify-between items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.server}:{network.port} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-300 text-green-800 dark:text-green-900">TLS</span>}</div>
|
<div className="col-span-4 flex justify-between items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.server}:{network.port} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-300 text-green-800 dark:text-green-900">TLS</span>}</div>
|
||||||
{network.nickserv && network.nickserv.account ? (
|
{network.nickserv && network.nickserv.account ? (
|
||||||
<div className="col-span-4 items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.nickserv.account}</div>
|
<div className="col-span-4 items-center sm:px-6 text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onClick={toggleEdit}>{network.nickserv.account}</div>
|
||||||
) : null}
|
) : <div className="col-span-4" />}
|
||||||
<div className="col-span-1 text-sm text-gray-500 dark:text-gray-400">
|
<div className="col-span-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
|
<span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
|
||||||
Edit
|
Edit
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
|
||||||
// allows you to do things like:
|
|
||||||
// expect(element).toHaveTextContent(/react/i)
|
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
|
||||||
import "@testing-library/jest-dom";
|
|
|
@ -65,3 +65,21 @@ export function slugify(str: string) {
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[-\s]+/g, "-");
|
.replace(/[-\s]+/g, "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WARNING: This is not a drop in replacement solution and
|
||||||
|
// it might not work for some edge cases. Test your code!
|
||||||
|
export const get = <T> (obj: T, path: string|Array<any>, defValue?: string) => {
|
||||||
|
// If path is not defined or it has false value
|
||||||
|
if (!path)
|
||||||
|
return undefined;
|
||||||
|
// Check if path is string or array. Regex : ensure that we do not have '.' and brackets.
|
||||||
|
// Regex explained: https://regexr.com/58j0k
|
||||||
|
const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g);
|
||||||
|
// Find value
|
||||||
|
const result = pathArray && pathArray.reduce(
|
||||||
|
(prevObj, key) => prevObj && prevObj[key],
|
||||||
|
obj
|
||||||
|
);
|
||||||
|
// If found value is undefined return default value; otherwise return the value
|
||||||
|
return result === undefined ? defValue : result;
|
||||||
|
};
|
3435
web/yarn.lock
3435
web/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue