refactor: various frontend improvements (#101)

* Removed recoil and replaced it with react-ridge-state, a 0.4kb alternative.

* Added AuthContext and SettingsContext persistent localStorage states.

* Fixed tailwind.config.js incorrect key directive. See https://tailwindcss.com/docs/content-configuration#safelisting-classes.

* Changed darkMode in Tailwind to "class" and started manually adjusting the theme according to the appropriate media query.

* Added possibility of changing the theme manually via the Settings tab.

* Changed Releases.tsx behavior to show the UI only when the HTTP request succeeded and there is some data (i.e. table is non-empty).

* Changed the table color of screens/filters/list.tsx to a one notch lighter shade of gray for eye-comfort.

* Replaced "User" in the header, with the users real username.

* Made data version, commit and date fields optional in settings/Application.tsx.

* Started working on a RegExp playground, which works fine, but JS won't cooperate and return the right match length. Either way, the RegExp must be implemented on backend and then must be communicated with the frontend. Otherwise a potential for incorrect results exists.

* Removed Layout.tsx, since it was redundant.

* Created a Checkbox component class for easier and consistent future use.

* Rewritten App.tsx, Login.tsx, Logout.tsx to accomodate for new changes.

* Fixed previous mistake regarding tailwind.config.js purge key, since we're still using old postcss7 from October last year

* Removed package-lock.json from both root and web directories.

* Refresh TypeScript configuration to support a types/ directory containing d.ts. The effect of this is that types don't have to be imported anymore and are at all times available globally. This also unifies them into a single source of truth, which will be a lot easier to manage in the future. Note: Only certain interop types have been moved at the time of writing.

* Fixed minor Checkbox argument mistake.

* fix: remove length from data check

* chore: lock files are annoying

* fix: select

* fix: wip release filtering
This commit is contained in:
stacksmash76 2022-01-26 23:54:29 +01:00 committed by GitHub
parent 53d75ef4d5
commit 20138030e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2596 additions and 2453 deletions

View file

@ -8,13 +8,6 @@
"@craco/craco": "^6.1.2",
"@headlessui/react": "^1.2.0",
"@heroicons/react": "^1.0.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"date-fns": "^2.25.0",
"formik": "^2.2.9",
"react": "^17.0.2",
@ -23,12 +16,11 @@
"react-hot-toast": "^2.1.1",
"react-multi-select-component": "^4.0.2",
"react-query": "^3.18.1",
"react-ridge-state": "^4.2.2",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-select": "5.0.0-beta.0",
"react-table": "^7.7.0",
"recoil": "^0.4.0",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
@ -56,11 +48,19 @@
]
},
"devDependencies": {
"@tailwindcss/forms": "^0.3.2",
"@types/react-router-dom": "^5.1.7",
"@types/react-table": "^7.7.7",
"autoprefixer": "^9",
"postcss": "^7",
"tailwindcss": "npm:@tailwindcss/postcss7-compat"
"typescript": "^4.1.2",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"@tailwindcss/forms": "^0.3.2",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/react-router-dom": "^5.1.7",
"@types/react-table": "^7.7.7",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0"
}
}

View file

@ -1,31 +1,42 @@
import { QueryClient, QueryClientProvider } from "react-query";
import { BrowserRouter as Router, Route } from "react-router-dom";
import { ReactQueryDevtools } from "react-query/devtools";
import { Toaster } from "react-hot-toast";
import Base from "./screens/Base";
import Login from "./screens/auth/login";
import Logout from "./screens/auth/logout";
import Base from "./screens/Base";
import { ReactQueryDevtools } from "react-query/devtools";
import Layout from "./components/Layout";
import { baseUrl } from "./utils";
import { AuthContext, SettingsContext } from "./utils/Context";
function Protected() {
return (
<Layout auth={true}>
<>
<Toaster position="top-right" />
<Base />
</Layout>
</>
)
}
export const queryClient = new QueryClient()
export const queryClient = new QueryClient();
function App() {
const authContext = AuthContext.useValue();
const settings = SettingsContext.useValue();
return (
<QueryClientProvider client={queryClient}>
<Router basename={baseUrl()}>
<Route exact={true} path="/login" component={Login} />
<Route exact={true} path="/logout" component={Logout} />
<Route exact={true} path="/*" component={Protected} />
{authContext.isLoggedIn ? (
<Route exact path="/*" component={Protected} />
) : (
<Route exact path="/*" component={Login} />
)}
<Route exact path="/logout" component={Logout} />
</Router>
<ReactQueryDevtools initialIsOpen={false} />
{settings.debug ? (
<ReactQueryDevtools initialIsOpen={false} />
) : null}
</QueryClientProvider>
)
};

View file

@ -1,4 +1,3 @@
import {Action, DownloadClient, Filter, Indexer, Network} from "../domain/interfaces";
import {baseUrl, sseBaseUrl} from "../utils";
function baseClient(endpoint: string, method: string, { body, ...customConfig}: any = {}) {

View file

@ -0,0 +1,34 @@
import { Switch } from "@headlessui/react";
interface CheckboxProps {
value: boolean;
setValue: (newValue: boolean) => void;
label: string;
description?: string;
}
export const Checkbox = ({ label, description, value, setValue }: CheckboxProps) => (
<Switch.Group as="li" className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900 dark:text-white" passive>
{label}
</Switch.Label>
{description === undefined ? null : (
<Switch.Description className="text-sm text-gray-500 dark:text-gray-400">
{description}
</Switch.Description>
)}
</div>
<Switch
checked={value}
onChange={setValue}
className={
`${value ? 'bg-teal-500 dark:bg-blue-500' : 'bg-gray-200 dark:bg-gray-700'
} ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
>
<span
className={`${value ? 'translate-x-5' : 'translate-x-0'} inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200`}
/>
</Switch>
</Switch.Group>
);

View file

@ -1,40 +0,0 @@
import {isLoggedIn} from "../state/state";
import {useRecoilState} from "recoil";
import {useEffect, useState} from "react";
import { Fragment } from "react";
import {Redirect} from "react-router-dom";
import APIClient from "../api/APIClient";
import { Toaster } from "react-hot-toast";
export default function Layout({auth=false, authFallback="/login", children}: any) {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
const [loading, setLoading] = useState(auth);
useEffect(() => {
// check token
APIClient.auth.test()
.then(r => {
setLoggedIn(true);
setLoading(false);
})
.catch(a => {
setLoading(false);
})
}, [setLoggedIn])
return (
<Fragment>
{loading ? null : (
<Fragment>
{auth && !loggedIn ? <Redirect to={authFallback} /> : (
<Fragment>
<Toaster position="top-right" />
{children}
</Fragment>
)}
</Fragment>
)}
</Fragment>
)
}

View file

@ -10,8 +10,8 @@ const DEBUG: FC<DebugProps> = ({ values }) => {
}
return (
<div className="w-1/2 mx-auto mt-2 flex flex-col mt-12 mb-12">
<pre className="mt-2 dark:text-gray-500">{JSON.stringify(values, 0 as any, 2)}</pre>
<div className="w-full p-2 flex flex-col mt-12 mb-12 bg-gray-100 dark:bg-gray-900">
<pre className="dark:text-gray-400">{JSON.stringify(values, 0 as any, 2)}</pre>
</div>
);
};

View file

@ -2,6 +2,6 @@ export { ErrorField, CheckboxField } from "./common";
export { TextField, NumberField, PasswordField } from "./input";
export { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "./input_wide";
export { RadioFieldsetWide } from "./radio";
export { DownloadClientSelect, MultiSelect, Select} from "./select";
export { MultiSelect, Select, SelectWide, DownloadClientSelect, IndexerMultiSelect} from "./select";
export { SwitchGroup } from "./switch";

View file

@ -2,7 +2,6 @@ import { Fragment } from "react";
import { MultiSelect as RMSC} from "react-multi-select-component";
import { Transition, Listbox } from "@headlessui/react";
import { CheckIcon, SelectorIcon } from '@heroicons/react/solid';
import { Action, DownloadClient } from "../../domain/interfaces";
import { classNames, COL_WIDTHS } from "../../utils";
import { Field } from "formik";
@ -56,6 +55,48 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
</div>
);
const IndexerMultiSelect: React.FC<MultiSelectProps> = ({
name,
label,
options,
className,
columns,
}) => (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
)}
>
<label
className="block mb-2 text-xs font-bold tracking-wide text-gray-700 uppercase dark:text-gray-200"
htmlFor={label}
>
{label}
</label>
<Field name={name} type="select" multiple={true}>
{({
field,
form: { setFieldValue },
}: any) => (
<RMSC
{...field}
type="select"
options={options}
labelledBy={name}
value={field.value && field.value.map((item: any) => options.find((o: any) => o.value?.id === item.id))}
onChange={(values: any) => {
let am = values && values.map((i: any) => i.value)
setFieldValue(field.name, am)
}}
className="dark:bg-gray-700 dark"
/>
)}
</Field>
</div>
);
interface DownloadClientSelectProps {
name: string;
action: Action;
@ -268,4 +309,105 @@ function Select({ name, label, optionDefaultText, options }: SelectFieldProps) {
);
}
export { MultiSelect, DownloadClientSelect, Select }
function SelectWide({ name, label, optionDefaultText, options }: SelectFieldProps) {
return (
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<div className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<Field name={name} type="select">
{({
field,
form: { setFieldValue },
}: any) => (
<Listbox
value={field.value}
onChange={(value: any) => setFieldValue(field?.name, value)}
>
{({ open }) => (
<div className="py-4 flex items-center justify-between">
<Listbox.Label className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
</Listbox.Label>
<div className="w-full">
<Listbox.Button className="bg-white dark:bg-gray-800 relative w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 dark:text-gray-200 sm:text-sm">
<span className="block truncate">
{field.value
? options.find((c) => c.value === field.value)!.label
: optionDefaultText
}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<SelectorIcon
className="h-5 w-5 text-gray-400 dark:text-gray-300"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
static
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
>
{options.map((opt) => (
<Listbox.Option
key={opt.value}
className={({ active }) =>
classNames(
active
? "text-white dark:text-gray-100 bg-indigo-600 dark:bg-gray-800"
: "text-gray-900 dark:text-gray-300",
"cursor-default select-none relative py-2 pl-3 pr-9"
)
}
value={opt.value}
>
{({ selected, active }) => (
<>
<span
className={classNames(
selected ? "font-semibold" : "font-normal",
"block truncate"
)}
>
{opt.label}
</span>
{selected ? (
<span
className={classNames(
active ? "text-white dark:text-gray-100" : "text-indigo-600 dark:text-gray-700",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</div>
)}
</Listbox>
)}
</Field>
</div>
</div>
);
}
export { MultiSelect, Select, SelectWide, DownloadClientSelect, IndexerMultiSelect }

View file

@ -1,5 +1,3 @@
import {DOWNLOAD_CLIENT_TYPES} from "./interfaces";
export const resolutions = [
"2160p",
"1080p",
@ -151,21 +149,46 @@ export const releaseTypeMusic = [
export const RELEASE_TYPE_MUSIC_OPTIONS = releaseTypeMusic.map(v => ({ value: v, label: v, key: v}));
export interface radioFieldsetOption {
export interface RadioFieldsetOption {
label: string;
description: string;
value: string;
value: ActionType;
}
export const DownloadClientTypeOptions: radioFieldsetOption[] = [
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: DOWNLOAD_CLIENT_TYPES.qBittorrent},
{label: "Deluge", description: "Add torrents directly to Deluge", value: DOWNLOAD_CLIENT_TYPES.DelugeV1},
{label: "Deluge 2", description: "Add torrents directly to Deluge 2", value: DOWNLOAD_CLIENT_TYPES.DelugeV2},
{label: "Radarr", description: "Send to Radarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Radarr},
{label: "Sonarr", description: "Send to Sonarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Sonarr},
{label: "Lidarr", description: "Send to Lidarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Lidarr},
export const DownloadClientTypeOptions: RadioFieldsetOption[] = [
{
label: "qBittorrent",
description: "Add torrents directly to qBittorrent",
value: "QBITTORRENT"
},
{
label: "Deluge",
description: "Add torrents directly to Deluge",
value: "DELUGE_V1"
},
{
label: "Deluge 2",
description: "Add torrents directly to Deluge 2",
value: "DELUGE_V2"
},
{
label: "Radarr",
description: "Send to Radarr and let it decide",
value: "RADARR"
},
{
label: "Sonarr",
description: "Send to Sonarr and let it decide",
value: "SONARR"
},
{
label: "Lidarr",
description: "Send to Lidarr and let it decide",
value: "LIDARR"
},
];
export const DownloadClientTypeNameMap = {
export const DownloadClientTypeNameMap: Record<DownloadClientType | string, string> = {
"DELUGE_V1": "Deluge v1",
"DELUGE_V2": "Deluge v2",
"QBITTORRENT": "qBittorrent",
@ -174,16 +197,16 @@ export const DownloadClientTypeNameMap = {
"LIDARR": "Lidarr",
};
export const ActionTypeOptions: radioFieldsetOption[] = [
export const ActionTypeOptions: RadioFieldsetOption[] = [
{label: "Test", description: "A simple action to test a filter.", value: "TEST"},
{label: "Watch dir", description: "Add filtered torrents to a watch directory", value: "WATCH_FOLDER"},
{label: "Exec", description: "Run a custom command after a filter match", value: "EXEC"},
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: "QBITTORRENT"},
{label: "Deluge", description: "Add torrents directly to Deluge", value: "DELUGE_V1"},
{label: "Deluge v2", description: "Add torrents directly to Deluge 2", value: "DELUGE_V2"},
{label: "Radarr", description: "Send to Radarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Radarr},
{label: "Sonarr", description: "Send to Sonarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Sonarr},
{label: "Lidarr", description: "Send to Lidarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Lidarr},
{label: "Radarr", description: "Send to Radarr and let it decide", value: "RADARR"},
{label: "Sonarr", description: "Send to Sonarr and let it decide", value: "SONARR"},
{label: "Lidarr", description: "Send to Lidarr and let it decide", value: "LIDARR"},
];
export const ActionTypeNameMap = {
@ -196,4 +219,4 @@ export const ActionTypeNameMap = {
"RADARR": "Radarr",
"SONARR": "Sonarr",
"LIDARR": "Lidarr",
};
};

View file

@ -1,224 +0,0 @@
export interface APP {
baseUrl: string;
}
export interface Action {
id: number;
name: string;
enabled: boolean;
type: ActionType;
exec_cmd: string;
exec_args: string;
watch_folder: string;
category: string;
tags: string;
label: string;
save_path: string;
paused: boolean;
ignore_rules: boolean;
limit_upload_speed: number;
limit_download_speed: number;
client_id: number;
filter_id: number;
// settings: object;
}
export interface Indexer {
id: number;
name: string;
identifier: string;
enabled: boolean;
settings: object | any;
}
export interface IndexerSchema {
// id: number;
name: string;
identifier: string;
description: string;
language: string;
privacy: string;
protocol: string;
urls: string[];
settings: IndexerSchemaSettings[];
irc: IndexerSchemaIRC;
}
export interface IndexerSchemaSettings {
name: string;
type: string;
required: boolean;
label: string;
help: string;
description: string;
default: string;
}
export interface IndexerSchemaIRC {
network: string;
server: string;
port: number;
tls: boolean;
nickserv: boolean;
announcers: string[];
channels: string[];
invite: string[];
invite_command: string;
settings: IndexerSchemaSettings[];
}
export interface Filter {
id: number;
name: string;
enabled: boolean;
shows: string;
min_size: string;
max_size: string;
match_sites: string[];
except_sites: string[];
delay: number;
years: string;
resolutions: string[];
sources: string[];
codecs: string[];
containers: string[];
match_release_types: string[];
quality: string[];
formats: string[];
match_hdr: string[];
except_hdr: string[];
log_score: number;
log: boolean;
cue: boolean;
perfect_flac: boolean;
artists: string;
albums: string;
seasons: string;
episodes: string;
match_releases: string;
except_releases: string;
match_release_groups: string;
except_release_groups: string;
match_categories: string;
except_categories: string;
tags: string;
except_tags: string;
match_uploaders: string;
except_uploaders: string;
freeleech: boolean;
freeleech_percent: string;
actions: Action[];
indexers: Indexer[];
}
export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2' | 'RADARR' | 'SONARR' | 'LIDARR';
export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE_V1', 'DELUGE_V2', 'RADARR', 'SONARR', 'LIDARR'];
export type DownloadClientType = 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2' | 'RADARR' | 'SONARR' | 'LIDARR';
export enum DOWNLOAD_CLIENT_TYPES {
qBittorrent = 'QBITTORRENT',
DelugeV1 = 'DELUGE_V1',
DelugeV2 = 'DELUGE_V2',
Radarr = 'RADARR',
Sonarr = 'SONARR',
Lidarr = 'LIDARR'
}
export interface DownloadClient {
id?: number;
name: string;
enabled: boolean;
host: string;
port: number;
ssl: boolean;
username: string;
password: string;
type: DownloadClientType;
settings: object;
}
export interface NickServ {
account: string;
password: string;
}
export interface Network {
id?: number;
name: string;
enabled: boolean;
server: string;
port: number;
tls: boolean;
invite_command: string;
nickserv: {
account: string;
password: string;
}
channels: Channel[];
settings: object;
}
export interface Channel {
name: string;
password: string;
}
export interface SASL {
mechanism: string;
plain: {
username: string;
password: string;
}
}
export interface Config {
host: string;
port: number;
log_level: string;
log_path: string;
base_url: string;
version: string;
commit: string;
date: string;
}
export interface Release {
id: number;
filter_status: string;
rejections: string[];
indexer: string;
filter: string;
protocol: string;
title: string;
size: number;
raw: string;
timestamp: Date
action_status: ReleaseActionStatus[]
}
export interface ReleaseActionStatus {
id: number;
status: string;
action: string;
type: string;
rejections: string[];
timestamp: Date
// raw: string;
}
export interface ReleaseFindResponse {
data: Release[];
next_cursor: number;
count: number;
}
export interface ReleaseStats {
total_count: number;
filtered_count: number;
filter_rejected_count: number;
push_approved_count: number;
push_rejected_count: number;
}

View file

@ -1,6 +1,5 @@
import { Fragment, useEffect } from "react";
import { useMutation } from "react-query";
import { Filter } from "../../domain/interfaces";
import { queryClient } from "../../App";
import { XIcon } from "@heroicons/react/solid";
import { Dialog, Transition } from "@headlessui/react";

View file

@ -1,13 +1,8 @@
import { Fragment, useRef, useState } from "react";
import { useMutation } from "react-query";
import {
DOWNLOAD_CLIENT_TYPES,
DownloadClient,
} from "../../domain/interfaces";
import { Dialog, Transition } from "@headlessui/react";
import { XIcon } from "@heroicons/react/solid";
import { sleep, classNames } from "../../utils";
import { Form, Formik, useFormikContext } from "formik";
import DEBUG from "../../components/debug";
import { queryClient } from "../../App";
@ -37,7 +32,7 @@ interface InitialValuesSettings {
interface InitialValues {
name: string;
type: DOWNLOAD_CLIENT_TYPES;
type: DownloadClientType;
enabled: boolean;
host: string;
port: number;
@ -326,7 +321,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: any) {
let initialValues: InitialValues = {
name: "",
type: DOWNLOAD_CLIENT_TYPES.qBittorrent,
type: "QBITTORRENT",
enabled: true,
host: "",
port: 10000,

View file

@ -1,12 +1,11 @@
import { Fragment } from "react";
import { useMutation, useQuery } from "react-query";
import { Channel, Indexer, IndexerSchema, IndexerSchemaSettings, Network } from "../../domain/interfaces";
import { sleep } from "../../utils";
import { XIcon } from "@heroicons/react/solid";
import { Dialog, Transition } from "@headlessui/react";
import { Field, FieldProps, Form, Formik } from "formik";
import DEBUG from "../../components/debug";
import Select, { components, InputProps } from "react-select";
import Select, { components } from "react-select";
import { queryClient } from "../../App";
import APIClient from "../../api/APIClient";
import { TextFieldWide, PasswordFieldWide, SwitchGroupWide } from "../../components/inputs/input_wide";
@ -15,7 +14,33 @@ import { toast } from 'react-hot-toast'
import Toast from '../../components/notifications/Toast';
import { SlideOver } from "../../components/panels";
const Input = ({ type, ...rest }: InputProps) => <components.Input {...rest} />;
const Input = (props: any) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
/>
);
}
const Control = (props: any) => {
return (
<components.Control
{...props}
className="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"
/>
);
}
const Menu = (props: any) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 hover:bg-red-800 rounded-md shadow-sm"
/>
);
}
interface AddProps {
isOpen: boolean;
@ -177,11 +202,9 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
irc: {},
settings: {},
}}
onSubmit={onSubmit}
>
{({ values }) => {
return (
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
@ -207,12 +230,12 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
</div>
</div>
<div className="py-6 space-y-6 py-0 space-y-0 divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<div className="py-6 space-y-6 space-y-0 divide-y divide-gray-200 dark:divide-gray-700">
<div className="py-4 flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
<div>
<label
htmlFor="identifier"
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"
>
Indexer
</label>
@ -223,7 +246,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
<Select {...field}
isClearable={true}
isSearchable={true}
components={{ Input }}
components={{ Input, Control, Menu }}
placeholder="Choose an indexer"
value={field?.value && field.value.value}
onChange={(option: any) => {
@ -233,7 +256,8 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
options={data && data.sort((a, b): any => a.name.localeCompare(b.name)).map(v => ({
label: v.name,
value: v.identifier
}))} />
}))}
/>
)}
</Field>
@ -273,8 +297,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
<DEBUG values={values} />
</Form>
)
}}
)}
</Formik>
</div>

View file

@ -1,5 +1,4 @@
import { useMutation } from "react-query";
import { Channel, Network } from "../../domain/interfaces";
import { XIcon } from "@heroicons/react/solid";
import { queryClient } from "../../App";

View file

@ -2,21 +2,22 @@ import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import {RecoilRoot} from 'recoil';
import {APP} from "./domain/interfaces";
import App from "./App";
import { InitializeGlobalContext } from "./utils/Context";
declare global {
interface Window { APP: APP; }
}
window.APP = window.APP || {};
// Initializes auth and theme contexts
InitializeGlobalContext();
ReactDOM.render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
<App />
</React.StrictMode>,
document.getElementById('root')
document.getElementById("root")
);

View file

@ -1,12 +1,16 @@
import { Fragment } from 'react'
import { Disclosure, Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon, MenuIcon, XIcon } from '@heroicons/react/outline'
import { Fragment } from "react";
import { NavLink, Link, Route, Switch } from "react-router-dom";
import { Disclosure, Menu, Transition } from "@headlessui/react";
import { ChevronDownIcon, MenuIcon, XIcon } from "@heroicons/react/outline";
import Logs from "./Logs";
import Settings from "./Settings";
import { Releases } from "./Releases";
import { Dashboard } from "./Dashboard";
import { FilterDetails, Filters } from "./filters";
import Logs from './Logs';
import { Releases } from "./Releases";
import { AuthContext } from '../utils/Context';
import logo from '../logo.png';
function classNames(...classes: string[]) {
@ -14,10 +18,17 @@ function classNames(...classes: string[]) {
}
export default function Base() {
const nav = [{ name: 'Dashboard', path: "/" }, { name: 'Filters', path: "/filters" }, { name: 'Releases', path: "/releases" }, { name: "Settings", path: "/settings" }, { name: "Logs", path: "/logs" }]
const authContext = AuthContext.useValue();
const nav = [
{ name: 'Dashboard', path: "/" },
{ name: 'Filters', path: "/filters" },
{ name: 'Releases', path: "/releases" },
{ name: "Settings", path: "/settings" },
{ name: "Logs", path: "/logs" }
];
return (
<div className="">
<div>
<Disclosure as="nav" className="bg-gray-900 pb-48">
{({ open }) => (
<>
@ -49,12 +60,6 @@ export default function Base() {
return true
}
// if (item.path ==="/" && location.pathname ==="/") {
// console.log("match base");
// return true
// }
if (!match) {
return false;
}
@ -86,7 +91,7 @@ export default function Base() {
className="max-w-xs rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<span
className="hidden text-gray-300 text-sm font-medium sm:block">
<span className="sr-only">Open user menu for </span>User
<span className="sr-only">Open user menu for </span>{authContext.username}
</span>
<ChevronDownIcon
className="hidden flex-shrink-0 ml-1 h-5 w-5 text-gray-400 sm:block"
@ -111,7 +116,7 @@ export default function Base() {
<Menu.Item>
{({ active }) => (
<Link
to="settings"
to="/settings"
className={classNames(
active ? 'bg-gray-100 dark:bg-gray-600' : '',
'block px-4 py-2 text-sm text-gray-700 dark:text-gray-200'
@ -186,7 +191,7 @@ export default function Base() {
</div>
<div className="mt-3 px-2 space-y-1">
<Link
to="settings"
to="/settings"
className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700"
>
Settings

View file

@ -4,7 +4,6 @@ import App from '../App'
import { useTable, useFilters, useGlobalFilter, useSortBy, usePagination } from 'react-table'
import APIClient from '../api/APIClient'
import { useQuery } from 'react-query'
import { ReleaseFindResponse, ReleaseStats } from '../domain/interfaces'
import { EmptyListState } from '../components/emptystates'
import { ReleaseStatusCell } from './Releases'

View file

@ -7,7 +7,6 @@ import { useQuery } from "react-query"
import { useTable, useSortBy, usePagination } from "react-table"
import APIClient from "../api/APIClient"
import { EmptyListState } from "../components/emptystates"
import { ReleaseActionStatus } from "../domain/interfaces"
import { classNames } from "../utils"
export function Releases() {
@ -294,24 +293,22 @@ function Table() {
// Render the UI for your table
return (
<>
<div className="sm:flex sm:gap-x-2">
{/* <GlobalFilter
preGlobalFilteredRows={preGlobalFilteredRows}
globalFilter={state.globalFilter}
setGlobalFilter={setGlobalFilter}
/> */}
{/* {headerGroups.map((headerGroup: { headers: any[] }) =>
headerGroup.headers.map((column) =>
column.Filter ? (
<div className="mt-2 sm:mt-0" key={column.id}>
{column.render("Filter")}
</div>
) : null
)
)} */}
</div>
{isSuccess ?
{isSuccess && data ? (
<div className="flex flex-col mt-4">
{/* <GlobalFilter
preGlobalFilteredRows={preGlobalFilteredRows}
globalFilter={state.globalFilter}
setGlobalFilter={setGlobalFilter}
/> */}
{/* {headerGroups.map((headerGroup: { headers: any[] }) =>
headerGroup.headers.map((column) =>
column.Filter ? (
<div className="mt-2 sm:mt-0" key={column.id}>
{column.render("Filter")}
</div>
) : null
)
)} */}
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-lg">
@ -373,7 +370,6 @@ function Table() {
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
@ -421,8 +417,7 @@ function Table() {
</PageButton>
<PageButton
onClick={() => nextPage()}
disabled={!canNextPage
}>
disabled={!canNextPage}>
<span className="sr-only">Next</span>
<ChevronRightIcon className="w-5 h-5 text-gray-400" aria-hidden="true" />
</PageButton>
@ -438,13 +433,11 @@ function Table() {
</div>
</div>
</div>
</div>
</div>
</div>
</div>
: <EmptyListState text="No recent activity" />}
) : <EmptyListState text="No recent activity" />}
</>
)
}

View file

@ -1,17 +1,19 @@
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom";
import IndexerSettings from "./settings/Indexer";
import IrcSettings from "./settings/Irc";
import ApplicationSettings from "./settings/Application";
import DownloadClientSettings from "./settings/DownloadClient";
import {classNames} from "../utils";
import ActionSettings from "./settings/Action";
import { RegexPlayground } from './settings/RegexPlayground';
const subNavigation = [
{name: 'Application', href: '', icon: CogIcon, current: true},
{name: 'Indexers', href: 'indexers', icon: KeyIcon, current: false},
{name: 'IRC', href: 'irc', icon: KeyIcon, current: false},
{name: 'Clients', href: 'clients', icon: DownloadIcon, current: false},
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
// {name: 'Actions', href: 'actions', icon: PlayIcon, current: false},
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
// {name: 'Notifications', href: 'notifications', icon: BellIcon, current: false},
@ -97,10 +99,13 @@ export default function Settings() {
<DownloadClientSettings/>
</Route>
<Route path={`${url}/actions`}>
{/*<Route path={`${url}/actions`}>
<ActionSettings/>
</Route>
</Route>*/}
<Route path={`${url}/regex-playground`}>
<RegexPlayground />
</Route>
</RouteSwitch>
</div>
</div>

View file

@ -1,40 +1,36 @@
import { useMutation } from "react-query";
import APIClient from "../../api/APIClient";
import { Form, Formik } from "formik";
import { useRecoilState } from "recoil";
import { isLoggedIn } from "../../state/state";
import { useHistory } from "react-router-dom";
import { useEffect } from "react";
import logo from "../../logo.png"
import { useMutation } from "react-query";
import { Form, Formik } from "formik";
import APIClient from "../../api/APIClient";
import { TextField, PasswordField } from "../../components/inputs";
interface loginData {
import logo from "../../logo.png";
import { AuthContext } from "../../utils/Context";
interface LoginData {
username: string;
password: string;
}
function Login() {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
let history = useHistory();
const history = useHistory();
const [, setAuthContext] = AuthContext.use();
useEffect(() => {
if (loggedIn) {
// setLoading(false);
history.push('/');
} else {
// setLoading(false);
const mutation = useMutation(
(data: LoginData) => APIClient.auth.login(data.username, data.password),
{
onSuccess: (_, variables: LoginData) => {
setAuthContext({
username: variables.username,
isLoggedIn: true
});
history.push("/");
},
}
}, [loggedIn, history])
);
const mutation = useMutation((data: loginData) => APIClient.auth.login(data.username, data.password), {
onSuccess: () => {
setLoggedIn(true);
},
})
const handleSubmit = (data: any) => {
mutation.mutate(data)
}
const handleSubmit = (data: any) => mutation.mutate(data);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
@ -45,27 +41,19 @@ function Login() {
alt="logo"
/>
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<Formik
initialValues={{
username: "",
password: "",
}}
initialValues={{ username: "", password: "" }}
onSubmit={handleSubmit}
>
{() => (
<Form>
<div className="space-y-6">
<TextField name="username" label="Username" columns={6} autoComplete="username" />
<PasswordField name="password" label="Password" columns={6} autoComplete="current-password" />
</div>
<div className="mt-6">
<button
type="submit"

View file

@ -1,23 +1,26 @@
import APIClient from "../../api/APIClient";
import {useRecoilState} from "recoil";
import {isLoggedIn} from "../../state/state";
import {useEffect} from "react";
import {useCookies} from "react-cookie";
import {useHistory} from "react-router-dom";
function Logout() {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
let history = useHistory();
import APIClient from "../../api/APIClient";
import { AuthContext } from "../../utils/Context";
function Logout() {
const history = useHistory();
const [, setAuthContext] = AuthContext.use();
const [,, removeCookie] = useCookies(['user_session']);
useEffect(() => {
APIClient.auth.logout().then(r => {
removeCookie("user_session")
setLoggedIn(false);
history.push('/login');
})
}, [loggedIn, history, removeCookie, setLoggedIn])
useEffect(
() => {
APIClient.auth.logout().then(r => {
setAuthContext({ username: "", isLoggedIn: false });
removeCookie("user_session");
history.push('/login');
})
},
[history, removeCookie, setAuthContext]
);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-800 flex flex-col justify-center py-12 sm:px-6 lg:px-8">

View file

@ -12,7 +12,6 @@ import {
useParams,
useRouteMatch
} from "react-router-dom";
import { Action, ActionType, DownloadClient, Filter, Indexer } from "../../domain/interfaces";
import { useToggle } from "../../hooks/hooks";
import { useMutation, useQuery } from "react-query";
import { queryClient } from "../../App";
@ -21,7 +20,6 @@ import { CONTAINER_OPTIONS, CODECS_OPTIONS, RESOLUTION_OPTIONS, SOURCES_OPTIONS,
import DEBUG from "../../components/debug";
import { TitleSubtitle } from "../../components/headings";
import { buildPath, classNames } from "../../utils";
import SelectM from "react-select";
import APIClient from "../../api/APIClient";
import { toast } from 'react-hot-toast'
@ -30,7 +28,7 @@ import Toast from '../../components/notifications/Toast';
import { Field, FieldArray, Form, Formik } from "formik";
import { AlertWarning } from "../../components/alerts";
import { DeleteModal } from "../../components/modals";
import { NumberField, TextField, SwitchGroup, Select, MultiSelect, DownloadClientSelect, CheckboxField } from "../../components/inputs";
import { NumberField, TextField, SwitchGroup, Select, MultiSelect, DownloadClientSelect, IndexerMultiSelect, CheckboxField } from "../../components/inputs";
const tabs = [
{ name: 'General', href: '', current: true },
@ -312,7 +310,12 @@ function General({ indexers }: GeneralProps) {
let opts = indexers ? indexers.map(v => ({
label: v.name,
value: v
value: {
id: v.id,
name: v.name,
identifier: v.identifier,
enabled: v.enabled
}
})) : [];
return (
@ -323,36 +326,7 @@ function General({ indexers }: GeneralProps) {
<TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
<div className="col-span-6">
<label htmlFor="indexers" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
Indexers
</label>
<Field name="indexers" type="select" multiple={true}>
{({
field,
form: { setFieldValue },
}: any) => {
return (
<SelectM
{...field}
value={field.value && field.value.map((v: any) => ({
label: v.name,
value: v
}))}
onChange={(values: any) => {
let am = values && values.map((i: any) => i.value)
setFieldValue(field.name, am)
}}
isClearable={true}
isMulti={true}
placeholder="Choose indexers"
className="mt-2 block w-full focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
options={opts}
/>
)
}}
</Field>
<IndexerMultiSelect name="indexers" options={opts} label="Indexers" columns={6} />
</div>
</div>
</div>
@ -375,12 +349,7 @@ function General({ indexers }: GeneralProps) {
);
}
// interface FilterTabGeneralProps {
// filter: Filter;
// }
function MoviesTv() {
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
@ -480,7 +449,7 @@ function Advanced() {
return (
<div>
<div className="mt-6 lg:pb-8 border-b border-gray-200 dark:border-gray-700">
<div className="mt-6 lg:pb-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center cursor-pointer" onClick={toggleReleases}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Releases</h3>
@ -503,7 +472,7 @@ function Advanced() {
)}
</div>
<div className="mt-6 lg:pb-8 border-b border-gray-200 dark:border-gray-700">
<div className="mt-6 lg:pb-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center cursor-pointer" onClick={toggleGroups}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Groups</h3>
@ -526,7 +495,7 @@ function Advanced() {
)}
</div>
<div className="mt-6 lg:pb-8 border-b border-gray-200 dark:border-gray-700">
<div className="mt-6 lg:pb-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center cursor-pointer" onClick={toggleCategories}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Categories and tags</h3>
@ -552,7 +521,7 @@ function Advanced() {
)}
</div>
<div className="mt-6 lg:pb-8 border-b border-gray-200 dark:border-gray-700">
<div className="mt-6 lg:pb-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center cursor-pointer" onClick={toggleUploaders}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Uploaders</h3>
@ -575,7 +544,7 @@ function Advanced() {
)}
</div>
<div className="mt-6 lg:pb-8 border-b border-gray-200 dark:border-gray-700">
<div className="mt-6 lg:pb-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex justify-between items-center cursor-pointer" onClick={toggleFreeleech}>
<div className="-ml-2 -mt-2 flex flex-wrap items-baseline">
<h3 className="ml-2 mt-2 text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Freeleech</h3>
@ -893,7 +862,7 @@ function FilterActionsItem({ action, clients, idx, remove }: FilterActionsItemPr
)}
</Field>
<button className="px-4 py-4 w-full flex block" type="button" onClick={toggleEdit}>
<button className="px-4 py-4 w-full flex" type="button" onClick={toggleEdit}>
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">

View file

@ -5,7 +5,6 @@ import { EmptyListState } from "../../components/emptystates";
import {
Link,
} from "react-router-dom";
import { Filter } from "../../domain/interfaces";
import { useToggle } from "../../hooks/hooks";
import { useMutation, useQuery } from "react-query";
import { classNames } from "../../utils";
@ -51,7 +50,7 @@ export default function Filters() {
</header>
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-900 light:rounded-lg light:shadow">
<div className="bg-white dark:bg-gray-800 light:rounded-lg light:shadow">
<div className="relative inset-0 light:py-3 light:px-3 light:sm:px-3 light:lg:px-3 h-full">
{data && data.length > 0 ? <FilterList filters={data} /> :
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />}
@ -72,8 +71,8 @@ function FilterList({ filters }: FilterListProps) {
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="light:py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 dark:border-gray-800 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
<tr>
<th
scope="col"
@ -98,7 +97,7 @@ function FilterList({ filters }: FilterListProps) {
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-800">
{filters.map((filter: Filter, idx) => (
<FilterListItem filter={filter} key={idx} idx={idx} />
))}
@ -135,7 +134,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
return (
<tr key={filter.name}
className={idx % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-900'}>
className={idx % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-800'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100">
<Switch
checked={enabled}
@ -161,7 +160,7 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{filter.indexers && filter.indexers.map(t =>
<span key={t.id} className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-400">{t.name}</span>)}</td>
<span key={t.id} className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-400">{t.name}</span>)}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link to={`filters/${filter.id.toString()}`} className="text-indigo-600 dark:text-gray-200 hover:text-indigo-900 dark:hover:text-gray-400">
Edit

View file

@ -70,32 +70,15 @@ function ActionSettings() {
<tr>
<td>empty</td>
</tr>
{/*{downloadclients.map((client, personIdx) => (*/}
{/* <tr key={client.name}*/}
{/* className={personIdx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{client.name}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{client.type}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{client.port}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{client.enabled}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">*/}
{/* <Link to="edit" className="text-indigo-600 hover:text-indigo-900">*/}
{/* Edit*/}
{/* </Link>*/}
{/* </td>*/}
{/* </tr>*/}
{/*))}*/}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
)
);
}
export default ActionSettings;

View file

@ -1,25 +1,22 @@
import React, { useState } from "react";
import { Switch } from "@headlessui/react";
import { classNames } from "../../utils";
// import {useRecoilState} from "recoil";
// import {configState} from "../../state/state";
import { useQuery } from "react-query";
import { Config } from "../../domain/interfaces";
import APIClient from "../../api/APIClient";
import { Checkbox } from "../../components/Checkbox";
import { SettingsContext } from "../../utils/Context";
function ApplicationSettings() {
const [isDebug, setIsDebug] = useState(true)
// const [config] = useRecoilState(configState)
const [settings, setSettings] = SettingsContext.use();
const { isLoading, data } = useQuery<Config, Error>(['config'], () => APIClient.config.get(),
const { isLoading, data } = useQuery<Config, Error>(
['config'],
() => APIClient.config.get(),
{
retry: false,
refetchOnWindowFocus: false,
onError: err => {
console.log(err)
}
},
)
onError: err => console.log(err)
}
);
return (
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST">
@ -32,7 +29,6 @@ function ApplicationSettings() {
</div>
{!isLoading && data && (
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6 sm:col-span-4">
<label htmlFor="host" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
@ -79,69 +75,53 @@ function ApplicationSettings() {
)}
</div>
<div className="pt-6 pb-6 divide-y divide-gray-200 dark:divide-gray-700">
<div className="pb-6 divide-y divide-gray-200 dark:divide-gray-700">
<div className="px-4 py-5 sm:p-0">
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700">
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-500 dark:text-white">Version:</dt>
<dd className="mt-1 font-semibold text-gray-900 dark:text-white sm:mt-0 sm:col-span-2">{data?.version}</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6 dark:bg-gray-700">
<dt className="font-medium text-gray-500 dark:text-white">Commit:</dt>
<dd className="mt-1 font-semibold text-gray-900 dark:text-white sm:mt-0 sm:col-span-2">{data?.commit}</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-500 dark:text-white">Date:</dt>
<dd className="mt-1 font-semibold text-gray-900 dark:text-white sm:mt-0 sm:col-span-2">{data?.date}</dd>
</div>
{data?.version ? (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-500 dark:text-white">Version:</dt>
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2">{data?.version}</dd>
</div>
) : null}
{data?.commit ? (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-500 dark:text-white">Commit:</dt>
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2">{data.commit}</dd>
</div>
) : null}
{data?.date ? (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-500 dark:text-white">Date:</dt>
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2">{data?.date}</dd>
</div>
) : null}
</dl>
</div>
<div className="px-4 sm:px-6">
<ul className="mt-2 divide-y divide-gray-200">
<Switch.Group as="li" className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900 dark:text-white" passive>
Debug
</Switch.Label>
<Switch.Description className="text-sm text-gray-500 dark:text-gray-400">
Enable debug mode to get more logs.
</Switch.Description>
</div>
<Switch
checked={isDebug}
disabled={true}
onChange={setIsDebug}
className={classNames(
isDebug ? 'bg-teal-500 dark:bg-blue-500' : 'bg-gray-200 dark:bg-gray-700',
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
isDebug ? 'translate-x-5' : 'translate-x-0',
'inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200'
)}
/>
</Switch>
</Switch.Group>
</ul>
</div>
{/*<div className="mt-4 py-4 px-4 flex justify-end sm:px-6">*/}
{/* <button*/}
{/* type="button"*/}
{/* className="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"*/}
{/* >*/}
{/* Cancel*/}
{/* </button>*/}
{/* <button*/}
{/* type="submit"*/}
{/* className="ml-5 bg-indigo-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"*/}
{/* >*/}
{/* Save*/}
{/* </button>*/}
{/*</div>*/}
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="px-4 sm:px-6 py-1">
<Checkbox
label="Debug"
description="Enable debug mode to get more logs."
value={settings.debug}
setValue={(newValue: boolean) => setSettings({
...settings,
debug: newValue
})}
/>
</div>
<div className="px-4 sm:px-6 py-1">
<Checkbox
label="Dark theme"
description="Switch between dark and light theme"
value={settings.darkTheme}
setValue={(newValue: boolean) => setSettings({
...settings,
darkTheme: newValue
})}
/>
</div>
</ul>
</div>
</form>

View file

@ -1,4 +1,3 @@
import { DownloadClient } from "../../domain/interfaces";
import { useToggle } from "../../hooks/hooks";
import { Switch } from "@headlessui/react";
import { useQuery } from "react-query";

View file

@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useToggle } from "../../hooks/hooks";
import { useQuery } from "react-query";
import { IndexerAddForm, IndexerUpdateForm } from "../../forms";
import { Indexer } from "../../domain/interfaces";
import { Switch } from "@headlessui/react";
import { classNames } from "../../utils";
import { EmptySimple } from "../../components/emptystates";

View file

@ -1,41 +1,11 @@
import { useEffect } from "react";
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "../../forms";
import { useToggle } from "../../hooks/hooks";
import { useQuery } from "react-query";
import { EmptySimple } from "../../components/emptystates";
import APIClient from "../../api/APIClient";
import { formatDistanceToNowStrict, formatISO9075 } from "date-fns";
interface IrcNetwork {
id: number;
name: string;
enabled: boolean;
addr: string;
server: string;
port: string;
nick: string;
username: string;
realname: string;
pass: string;
connected: boolean;
connected_since: string;
tls: boolean;
nickserv: {
account: string;
}
channels: Channel[]
}
import APIClient from "../../api/APIClient";
import { useToggle } from "../../hooks/hooks";
import { EmptySimple } from "../../components/emptystates";
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "../../forms";
interface Channel {
id: number;
enabled: boolean;
name: string;
password: string;
detached: boolean;
monitoring: boolean;
monitoring_since: string;
last_announce: string;
}
function IsEmptyDate(date: string) {
if (date !== "0001-01-01T00:00:00Z") {
@ -57,9 +27,6 @@ function simplifyDate(date: string) {
function IrcSettings() {
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false)
useEffect(() => {
}, []);
const { data } = useQuery<IrcNetwork[], Error>('networks', APIClient.irc.getNetworks,
{
refetchOnWindowFocus: false
@ -167,13 +134,11 @@ const LiItem = ({ idx, network }: LiItemProps) => {
Edit
</span>
</div>
</div>
{edit && (
<div className="px-4 py-4 flex border-b border-x-0 dark:border-gray-600 dark:bg-gray-700">
<div className="min-w-full">
<ol>
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Channel</div>
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Monitoring since</div>
@ -181,9 +146,7 @@ const LiItem = ({ idx, network }: LiItemProps) => {
</li>
{network.channels.map(c => (
<li key={c.id} className="text-gray-500 dark:text-gray-400">
<div className="grid grid-cols-12 gap-4 items-center py-4">
<div className="col-span-4 flex items-center sm:px-6 ">
<span className="relative inline-flex items-center">
{

View file

@ -0,0 +1,99 @@
import React, { useRef, useState } from "react";
export 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> = [];
text.split("\n").forEach((line, index) => {
const matches = line.matchAll(regexp);
let lastIndex = 0;
// @ts-ignore
for (const match of matches) {
if (match.index === undefined)
continue;
const start = match.index;
results.push(
<span key={`match=${start}`}>
{line.substring(lastIndex, start)}
<span className="bg-green-200 text-black font-bold">
{line.substring(start, start + match.length)}
</span>
</span>
);
lastIndex = start + match.length;
}
if (lastIndex < line.length) {
results.push(
<span key={`last-${lastIndex + 1}`}>
{line.substring(lastIndex)}
</span>
);
}
if (lastIndex > 0)
results.push(<br key={`line-delim-${index}`}/>);
});
setOutput(results);
}
return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Application</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Regex playground. Experiment with your filters here. WIP.
</p>
</div>
</div>
<div className="px-6 py-4">
<label
htmlFor="input-regex"
className="block text-sm font-medium text-gray-300"
>
RegExp filter
</label>
<input
ref={regexRef}
id="input-regex"
type="text"
autoComplete="true"
className="mt-1 mb-4 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
/>
<label
htmlFor="input-lines"
className="block text-sm font-medium text-gray-300"
>
Lines to match
</label>
<div
id="input-lines"
className="mt-1 mb-4 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
onInput={(e) => onInput(e.currentTarget.innerText ?? "")}
contentEditable
></div>
</div>
<div className="py-4 px-4 sm:p-6 lg:pb-8">
<div>
<h3 className="text-md leading-6 font-medium text-gray-900 dark:text-white">
Matches
</h3>
<p className="mt-1 text-lg text-gray-500 dark:text-gray-400">
{output}
</p>
</div>
</div>
</div>
);
}

View file

@ -1,17 +0,0 @@
import { atom } from "recoil";
export const configState = atom({
key: "configState",
default: {
host: "127.0.0.1",
port: 8989,
base_url: "",
log_path: "",
log_level: "DEBUG",
}
});
export const isLoggedIn = atom({
key: 'isLoggedIn',
default: false,
})

65
web/src/types/Action.d.ts vendored Normal file
View file

@ -0,0 +1,65 @@
interface Action {
id: number;
name: string;
enabled: boolean;
type: ActionType;
exec_cmd: string;
exec_args: string;
watch_folder: string;
category: string;
tags: string;
label: string;
save_path: string;
paused: boolean;
ignore_rules: boolean;
limit_upload_speed: number;
limit_download_speed: number;
client_id: number;
filter_id: number;
}
interface Filter {
id: number;
name: string;
enabled: boolean;
shows: string;
min_size: string;
max_size: string;
match_sites: string[];
except_sites: string[];
delay: number;
years: string;
resolutions: string[];
sources: string[];
codecs: string[];
containers: string[];
match_release_types: string[];
quality: string[];
formats: string[];
match_hdr: string[];
except_hdr: string[];
log_score: number;
log: boolean;
cue: boolean;
perfect_flac: boolean;
artists: string;
albums: string;
seasons: string;
episodes: string;
match_releases: string;
except_releases: string;
match_release_groups: string;
except_release_groups: string;
match_categories: string;
except_categories: string;
tags: string;
except_tags: string;
match_uploaders: string;
except_uploaders: string;
freeleech: boolean;
freeleech_percent: string;
actions: Action[];
indexers: Indexer[];
}
type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | DownloadClientType;

20
web/src/types/Download.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
type DownloadClientType =
'QBITTORRENT' |
'DELUGE_V1' |
'DELUGE_V2' |
'RADARR' |
'SONARR' |
'LIDARR';
interface DownloadClient {
id?: number;
name: string;
enabled: boolean;
host: string;
port: number;
ssl: boolean;
username: string;
password: string;
type: DownloadClientType;
settings: object;
}

3
web/src/types/Global.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
interface APP {
baseUrl: string;
}

42
web/src/types/Indexer.d.ts vendored Normal file
View file

@ -0,0 +1,42 @@
interface Indexer {
id: number;
name: string;
identifier: string;
enabled: boolean;
settings: object | any;
}
interface IndexerSchema {
name: string;
identifier: string;
description: string;
language: string;
privacy: string;
protocol: string;
urls: string[];
settings: IndexerSchemaSettings[];
irc: IndexerSchemaIRC;
}
interface IndexerSchemaSettings {
name: string;
type: string;
required: boolean;
label: string;
help: string;
description: string;
default: string;
}
interface IndexerSchemaIRC {
network: string;
server: string;
port: number;
tls: boolean;
nickserv: boolean;
announcers: string[];
channels: string[];
invite: string[];
invite_command: string;
settings: IndexerSchemaSettings[];
}

75
web/src/types/Irc.d.ts vendored Normal file
View file

@ -0,0 +1,75 @@
interface IrcNetwork {
id: number;
name: string;
enabled: boolean;
addr: string;
server: string;
port: string;
nick: string;
username: string;
realname: string;
pass: string;
connected: boolean;
connected_since: string;
tls: boolean;
nickserv: {
account: string;
}
channels: IrcNetworkChannel[];
}
interface IrcNetworkChannel {
id: number;
enabled: boolean;
name: string;
password: string;
detached: boolean;
monitoring: boolean;
monitoring_since: string;
last_announce: string;
}
interface NickServ {
account: string;
password: string;
}
interface Network {
id?: number;
name: string;
enabled: boolean;
server: string;
port: number;
tls: boolean;
invite_command: string;
nickserv: {
account: string;
password: string;
}
channels: Channel[];
settings: object;
}
interface Channel {
name: string;
password: string;
}
interface SASL {
mechanism: string;
plain: {
username: string;
password: string;
}
}
interface Config {
host: string;
port: number;
log_level: string;
log_path: string;
base_url: string;
version: string;
commit: string;
date: string;
}

36
web/src/types/Release.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
interface Release {
id: number;
filter_status: string;
rejections: string[];
indexer: string;
filter: string;
protocol: string;
title: string;
size: number;
raw: string;
timestamp: Date
action_status: ReleaseActionStatus[]
}
interface ReleaseActionStatus {
id: number;
status: string;
action: string;
type: string;
rejections: string[];
timestamp: Date
}
interface ReleaseFindResponse {
data: Release[];
next_cursor: number;
count: number;
}
interface ReleaseStats {
total_count: number;
filtered_count: number;
filter_rejected_count: number;
push_approved_count: number;
push_rejected_count: number;
}

71
web/src/utils/Context.ts Normal file
View file

@ -0,0 +1,71 @@
import { newRidgeState } from "react-ridge-state";
export const InitializeGlobalContext = () => {
const auth_ctx = localStorage.getItem("auth");
if (auth_ctx)
AuthContext.set(JSON.parse(auth_ctx));
const settings_ctx = localStorage.getItem("settings");
if (settings_ctx) {
SettingsContext.set(JSON.parse(settings_ctx));
} else {
// Only check for light theme, otherwise dark theme is the default
const userMedia = window.matchMedia("(prefers-color-scheme: light)");
if (userMedia.matches) {
SettingsContext.set((state) => ({
...state,
darkTheme: false
}));
}
}
}
interface AuthInfo {
username: string;
isLoggedIn: boolean;
}
export const AuthContext = newRidgeState<AuthInfo>(
{
username: "",
isLoggedIn: false
},
{
onSet: (new_state) => {
try {
localStorage.setItem("auth", JSON.stringify(new_state));
} catch (e) {
console.log("An error occurred while trying to modify the local auth context state.");
console.log("Error:", e);
}
}
}
);
interface SettingsType {
debug: boolean;
darkTheme: boolean;
}
export const SettingsContext = newRidgeState<SettingsType>(
{
debug: false,
darkTheme: true
},
{
onSet: (new_state) => {
try {
if (new_state.darkTheme) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("settings", JSON.stringify(new_state));
} catch (e) {
console.log("An error occurred while trying to modify the local settings context state.");
console.log("Error:", e);
}
}
}
);

View file

@ -1,7 +1,6 @@
const colors = require('tailwindcss/colors')
module.exports = {
// mode: 'jit',
purge: {
content: [
'./src/**/*.{tsx,ts,html,css}',
@ -21,7 +20,7 @@ module.exports = {
'col-span-12',
],
},
darkMode: 'media', // or 'media' or 'class'
darkMode: 'class', // or 'media' or 'class'
theme: {
extend: {
colors: {

View file

@ -1,26 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": true,
"types": [],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
"./src",
"./types"
]
}

File diff suppressed because it is too large Load diff