feat: add webui

This commit is contained in:
Ludvig Lundgren 2021-08-11 15:27:48 +02:00
parent a838d994a6
commit 773e57afe6
59 changed files with 19794 additions and 0 deletions

46
web/README.md Normal file
View file

@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

61
web/build.go Normal file
View file

@ -0,0 +1,61 @@
// Package web web/build.go
package web
import (
"embed"
"html/template"
"io"
"io/fs"
"net/http"
"os"
"path"
)
//go:embed build
var Assets embed.FS
// fsFunc is short-hand for constructing a http.FileSystem
// implementation
type fsFunc func(name string) (fs.File, error)
func (f fsFunc) Open(name string) (fs.File, error) {
return f(name)
}
// AssetHandler returns a http.Handler that will serve files from
// the Assets embed.FS. When locating a file, it will strip the given
// prefix from the request and prepend the root to the filesystem
// lookup: typical prefix might be /web/, and root would be build.
func AssetHandler(prefix, root string) http.Handler {
handler := fsFunc(func(name string) (fs.File, error) {
assetPath := path.Join(root, name)
// If we can't find the asset, return the default index.html
// content
f, err := Assets.Open(assetPath)
if os.IsNotExist(err) {
return Assets.Open("build/index.html")
}
// Otherwise, assume this is a legitimate request routed
// correctly
return f, err
})
return http.StripPrefix(prefix, http.FileServer(http.FS(handler)))
}
type IndexParams struct {
Title string
Version string
BaseUrl string
}
func Index(w io.Writer, p IndexParams) error {
return parseIndex().Execute(w, p)
}
func parseIndex() *template.Template {
return template.Must(
template.New("index.html").ParseFS(Assets, "build/index.html"))
}

10
web/craco.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
style: {
postcss: {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
},
},
}

64
web/package.json Normal file
View file

@ -0,0 +1,64 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"proxy": "http://localhost:8989",
"homepage": ".",
"dependencies": {
"@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",
"final-form": "^4.20.2",
"final-form-arrays": "^3.0.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-final-form": "^6.5.3",
"react-final-form-arrays": "^3.1.3",
"react-multi-select-component": "^4.0.2",
"react-query": "^3.18.1",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-select": "5.0.0-beta.0",
"recoil": "^0.4.0",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@tailwindcss/forms": "^0.3.2",
"@types/react-router-dom": "^5.1.7",
"autoprefixer": "^9",
"postcss": "^7",
"tailwindcss": "npm:@tailwindcss/postcss7-compat"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

33
web/public/index.html Normal file
View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="autobrr"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link crossorigin="use-credentials" rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>autobrr</title>
{{if eq .BaseUrl "/" }}
<base href="%PUBLIC_URL%/">
<script>
window.APP = {}
window.APP.baseUrl = "/"
</script>
{{else}}
<base href="{{.BaseUrl}}">
<script>
window.APP = {}
window.APP.baseUrl = "{{.BaseUrl}}"
</script>
{{end}}
</head>
<body class="bg-gray-100">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
web/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
web/public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
web/public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

110
web/src/api/APIClient.ts Normal file
View file

@ -0,0 +1,110 @@
import {Action, DownloadClient, Filter, Indexer, Network} from "../domain/interfaces";
function baseClient(endpoint: string, method: string, { body, ...customConfig}: any = {}) {
let baseUrl = ""
if (window.APP.baseUrl) {
if (window.APP.baseUrl === '/') {
baseUrl = "/"
} else if (window.APP.baseUrl === `{{.BaseUrl}}`) {
baseUrl = ""
} else if (window.APP.baseUrl === "/autobrr/") {
baseUrl = "/autobrr/"
} else {
baseUrl = window.APP.baseUrl
}
}
const headers = {'content-type': 'application/json'}
const config = {
method: method,
...customConfig,
headers: {
...headers,
...customConfig.headers,
},
}
if (body) {
config.body = JSON.stringify(body)
}
return window.fetch(`${baseUrl}${endpoint}`, config)
.then(async response => {
if (response.status === 401) {
// unauthorized
// window.location.assign(window.location)
return
}
if (response.status === 404) {
return Promise.reject(new Error(response.statusText))
}
if (response.status === 201) {
return ""
}
if (response.status === 204) {
return ""
}
if (response.ok) {
return await response.json()
} else {
const errorMessage = await response.text()
return Promise.reject(new Error(errorMessage))
}
})
}
const appClient = {
Get: (endpoint: string) => baseClient(endpoint, "GET"),
Post: (endpoint: string, data: any) => baseClient(endpoint, "POST", { body: data }),
Put: (endpoint: string, data: any) => baseClient(endpoint, "PUT", { body: data }),
Patch: (endpoint: string, data: any) => baseClient(endpoint, "PATCH", { body: data }),
Delete: (endpoint: string) => baseClient(endpoint, "DELETE"),
}
const APIClient = {
actions: {
create: (action: Action) => appClient.Post("api/actions", action),
update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action),
delete: (id: number) => appClient.Delete(`api/actions/${id}`),
toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`, null),
},
config: {
get: () => appClient.Get("api/config")
},
download_clients: {
getAll: () => appClient.Get("api/download_clients"),
create: (dc: DownloadClient) => appClient.Post(`api/download_clients`, dc),
update: (dc: DownloadClient) => appClient.Put(`api/download_clients`, dc),
delete: (id: number) => appClient.Delete(`api/download_clients/${id}`),
test: (dc: DownloadClient) => appClient.Post(`api/download_clients/test`, dc),
},
filters: {
getAll: () => appClient.Get("api/filters"),
getByID: (id: number) => appClient.Get(`api/filters/${id}`),
create: (filter: Filter) => appClient.Post(`api/filters`, filter),
update: (filter: Filter) => appClient.Put(`api/filters/${filter.id}`, filter),
delete: (id: number) => appClient.Delete(`api/filters/${id}`),
},
indexers: {
getOptions: () => appClient.Get("api/indexer/options"),
getAll: () => appClient.Get("api/indexer"),
getSchema: () => appClient.Get("api/indexer/schema"),
create: (indexer: Indexer) => appClient.Post(`api/indexer`, indexer),
update: (indexer: Indexer) => appClient.Put(`api/indexer`, indexer),
delete: (id: number) => appClient.Delete(`api/indexer/${id}`),
},
irc: {
getNetworks: () => appClient.Get("api/irc"),
createNetwork: (network: Network) => appClient.Post(`api/irc`, network),
updateNetwork: (network: Network) => appClient.Put(`api/irc/network/${network.id}`, network),
deleteNetwork: (id: number) => appClient.Delete(`api/irc/network/${id}`),
},
}
export default APIClient;

View file

@ -0,0 +1,24 @@
import React from "react";
interface props {
text: string;
buttonText?: string;
buttonOnClick?: any;
}
export function EmptyListState({ text, buttonText, buttonOnClick }: props) {
return (
<div className="px-4 py-12 flex flex-col items-center">
<p className="text-center text-gray-500">{text}</p>
{buttonText && buttonOnClick && (
<button
type="button"
className="relative inline-flex items-center px-4 py-2 mt-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={buttonOnClick}
>
{buttonText}
</button>
)}
</div>
)
}

View file

@ -0,0 +1,665 @@
import {Action, DownloadClient} from "../domain/interfaces";
import React, {Fragment, useEffect, useRef } from "react";
import {Dialog, Listbox, Switch, Transition} from '@headlessui/react'
import {classNames} from "../styles/utils";
import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid";
import {useToggle} from "../hooks/hooks";
import {useMutation} from "react-query";
import {queryClient} from "..";
import {Field, Form} from "react-final-form";
import {TextField} from "./inputs";
import DEBUG from "./debug";
import APIClient from "../api/APIClient";
interface radioFieldsetOption {
label: string;
value: string;
}
const actionTypeOptions: radioFieldsetOption[] = [
{label: "Test", value: "TEST"},
{label: "Watch dir", value: "WATCH_FOLDER"},
{label: "Exec", value: "EXEC"},
{label: "qBittorrent", value: "QBITTORRENT"},
{label: "Deluge", value: "DELUGE"},
];
interface FilterListProps {
actions: Action[];
clients: DownloadClient[];
filterID: number;
}
export function FilterActionList({actions, clients, filterID}: FilterListProps) {
useEffect(() => {
// console.log("render list")
}, [])
return (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{actions.map((action, idx) => (
<ListItem action={action} clients={clients} filterID={filterID} key={action.id} idx={idx} />
))}
</ul>
</div>
)
}
interface ListItemProps {
action: Action;
clients: DownloadClient[];
filterID: number;
idx: number;
}
function ListItem({action, clients, filterID, idx}: ListItemProps) {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
const [edit, toggleEdit] = useToggle(false)
const deleteMutation = useMutation((actionID: number) => APIClient.actions.delete(actionID), {
onSuccess: () => {
queryClient.invalidateQueries(['filter',filterID]);
toggleDeleteModal()
}
})
const enabledMutation = useMutation((actionID: number) => APIClient.actions.toggleEnable(actionID), {
onSuccess: () => {
queryClient.invalidateQueries(['filter',filterID]);
}
})
const updateMutation = useMutation((action: Action) => APIClient.actions.update(action), {
onSuccess: () => {
queryClient.invalidateQueries(['filter',filterID]);
}
})
const toggleActive = () => {
enabledMutation.mutate(action.id)
}
useEffect(() => {
}, [action])
const cancelButtonRef = useRef(null)
const deleteAction = () => {
deleteMutation.mutate(action.id)
}
const onSubmit = (action: Action) => {
// TODO clear data depending on type
updateMutation.mutate(action)
};
const TypeForm = (action: Action) => {
switch (action.type) {
case "TEST":
return (
<div className="py-4">
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true"/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Notice</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
The test action does nothing except to show if the filter works.
</p>
</div>
</div>
</div>
</div>
</div>
)
case "EXEC":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="exec_cmd" label="Command" columns={6} placeholder="Path to program eg. /bin/test"/>
<TextField name="exec_args" label="Arguments" columns={6} placeholder="Arguments eg. --test"/>
</div>
</div>
)
case "WATCH_FOLDER":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="watch_folder" label="Watch folder" columns={6} placeholder="Watch directory eg. /home/user/rwatch"/>
</div>
)
case "QBITTORRENT":
return (
<div className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6 sm:col-span-6">
<Field
name="client_id"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Client</Listbox.Label>
<div className="mt-2 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span
className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</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">
<SelectorIcon className="h-5 w-5 text-gray-400" 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 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"
>
{clients.filter((c) => c.type === action.type).map((client: any) => (
<Listbox.Option
key={client.id}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9'
)
}
value={client.id}
>
{({selected, active}) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
{client.name}
</span>
{selected ? (
<span
className={classNames(
active ? 'text-white' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)}/>
</div>
<div className="col-span-6 sm:col-span-6">
<TextField name="save_path" label="Save path" columns={6}/>
</div>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="category" label="Category" columns={6}/>
<TextField name="tags" label="Tags" columns={6}/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-12 sm:col-span-6">
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Limit upload speed (kb/s)
</label>
<Field name="limit_upload_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div className="col-span-12 sm:col-span-6">
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Limit download speed (kb/s)
</label>
<Field name="limit_download_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
)
case "DELUGE":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-12 sm:col-span-6">
<Field
name="client_id"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Client</Listbox.Label>
<div className="mt-2 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span
className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</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">
<SelectorIcon className="h-5 w-5 text-gray-400" 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 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"
>
{clients.filter((c) => c.type === action.type).map((client: any) => (
<Listbox.Option
key={client.id}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9'
)
}
value={client.id}
>
{({selected, active}) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
{client.name}
</span>
{selected ? (
<span
className={classNames(
active ? 'text-white' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)}/>
</div>
<div className="col-span-12 sm:col-span-6">
<TextField name="save_path" label="Save path" columns={6}/>
</div>
</div>
<div className="mt-6 col-span-12 sm:col-span-6">
<TextField name="label" label="Label" columns={6}/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-12 sm:col-span-6">
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Limit upload speed (kb/s)
</label>
<Field name="limit_upload_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div className="col-span-12 sm:col-span-6">
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Limit download speed (kb/s)
</label>
<Field name="limit_download_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
)
default:
return <p>default</p>
}
}
return (
<li key={action.id}>
<div className={classNames(idx % 2 === 0 ? 'bg-white' : 'bg-gray-50', "flex items-center sm:px-6 hover:bg-gray-50")}>
<Switch
checked={action.enabled}
onChange={toggleActive}
className={classNames(
action.enabled ? 'bg-teal-500' : 'bg-gray-200',
'z-10 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-light-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
action.enabled ? '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>
<button className="px-4 py-4 w-full flex block" 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">
<p className="ml-4 font-medium text-indigo-600 truncate">{action.name}</p>
</div>
</div>
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden -space-x-1">
<span className="text-sm font-normal text-gray-500">{action.type}</span>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
</div>
</button>
</div>
{edit &&
<div className="px-4 py-4 flex items-center sm:px-6">
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={cancelButtonRef}
open={deleteModalIsOpen}
onClose={toggleDeleteModal}
>
<div
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true"/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3"
className="text-lg leading-6 font-medium text-gray-900">
Remove filter action
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to remove this action?
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={deleteAction}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={toggleDeleteModal}
ref={cancelButtonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<Form
initialValues={{
id: action.id,
name: action.name,
enabled: action.enabled,
type: action.type,
watch_folder: action.watch_folder,
exec_cmd: action.exec_cmd,
exec_args: action.exec_args,
category: action.category,
tags: action.tags,
label: action.label,
save_path: action.save_path,
paused: action.paused,
ignore_rules: action.ignore_rules,
limit_upload_speed: action.limit_upload_speed || 0,
limit_download_speed: action.limit_download_speed || 0,
filter_id: action.filter_id,
client_id: action.client_id,
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form onSubmit={handleSubmit} className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6">
<Field
name="type"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Type</Listbox.Label>
<div className="mt-2 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span
className="block truncate">{input.value ? actionTypeOptions.find(c => c.value === input.value)!.label : "Choose a type"}</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" 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 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"
>
{actionTypeOptions.map((opt) => (
<Listbox.Option
key={opt.value}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'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' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)}/>
</div>
<TextField name="name" label="Name" columns={6}/>
</div>
{TypeForm(values)}
<div className="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div>
<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-light-blue-500"
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
}
</li>
)
}

View file

@ -0,0 +1,15 @@
import React from "react";
const DEBUG = ({ values }: any) => {
if (process.env.NODE_ENV !== "development") {
return null;
}
return (
<div className="w-1/2 mx-auto mt-2 flex flex-col mt-12 mb-12">
<pre className="mt-2">{JSON.stringify(values, 0 as any, 2)}</pre>
</div>
);
};
export default DEBUG;

View file

@ -0,0 +1,27 @@
import {PlusIcon} from "@heroicons/react/solid";
interface props {
title: string;
subtitle: string;
buttonText: string;
buttonAction: any;
}
const EmptySimple = ({ title, subtitle, buttonText, buttonAction}: props) => (
<div className="text-center py-8">
<h3 className="mt-2 text-sm font-medium text-gray-900">{title}</h3>
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
<div className="mt-6">
<button
type="button"
onClick={buttonAction}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
{buttonText}
</button>
</div>
</div>
)
export default EmptySimple;

View file

@ -0,0 +1,15 @@
import React from "react";
interface Props {
title: string;
subtitle: string;
}
const TitleSubtitle: React.FC<Props> = ({ title, subtitle }) => (
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">{title}</h2>
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
</div>
)
export default TitleSubtitle;

View file

@ -0,0 +1,20 @@
import React from "react";
import { Field } from "react-final-form";
interface Props {
name: string;
classNames?: string;
subscribe?: any;
}
const Error: React.FC<Props> = ({ name, classNames }) => (
<Field
name={name}
subscribe={{ touched: true, error: true }}
render={({ meta: { touched, error } }) =>
touched && error ? <span className={classNames}>{error}</span> : null
}
/>
);
export default Error;

View file

@ -0,0 +1,50 @@
import React from "react";
import {Field} from "react-final-form";
import MultiSelect from "react-multi-select-component";
import {classNames, COL_WIDTHS} from "../../styles/utils";
interface Props {
label?: string;
options?: [] | any;
name: string;
className?: string;
columns?: COL_WIDTHS;
}
const MultiSelectField: React.FC<Props> = ({
name,
label,
options,
className,
columns
}) => (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
)}
>
<label
className="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
htmlFor={label}
>
{label}
</label>
<Field
name={name}
parse={val => val && val.map((item: any) => item.value)}
format={val =>
val &&
val.map((item: any) => options.find((o: any) => o.value === item))
}
render={({input, meta}) => (
<MultiSelect
{...input}
options={options}
labelledBy={name}
/>
)}
/>
</div>
);
export default MultiSelectField;

View file

@ -0,0 +1,60 @@
import React from "react";
import {Field} from "react-final-form";
export interface radioFieldsetOption {
label: string;
description: string;
value: string;
}
interface props {
name: string;
legend: string;
options: radioFieldsetOption[];
}
const RadioFieldset: React.FC<props> = ({ name, legend,options }) => (
<fieldset>
<div className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
<div>
<legend className="text-sm font-medium text-gray-900">{legend}</legend>
</div>
<div className="space-y-5 sm:col-span-2">
<div className="space-y-5 sm:mt-0">
{options.map((opt, idx) => (
<div className="relative flex items-start" key={idx}>
<div className="absolute flex items-center h-5">
<Field
name={name}
type="radio"
render={({input}) => (
<input
{...input}
id={name}
value={opt.value}
// type="radio"
checked={input.checked}
className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300"
/>
)}
/>
</div>
<div className="pl-7 text-sm">
<label htmlFor={opt.value} className="font-medium text-gray-900">
{opt.label}
</label>
<p id={opt.value+"_description"} className="text-gray-500">
{opt.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</fieldset>
)
export default RadioFieldset;

View file

@ -0,0 +1,55 @@
import React from "react";
import {Switch} from "@headlessui/react";
import {Field} from "react-final-form";
import {classNames} from "../../styles/utils";
interface Props {
name: string;
label: string;
description?: string;
className?: string;
}
const SwitchGroup: React.FC<Props> = ({name, label, description}) => (
<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"
passive>
{label}
</Switch.Label>
{description && (
<Switch.Description className="text-sm text-gray-500">
{description}
</Switch.Description>
)}
</div>
<Field
name={name}
render={({input: {onChange, checked, value}}) => (
<Switch
value={value}
checked={value}
onChange={onChange}
className={classNames(
value ? 'bg-teal-500' : 'bg-gray-200',
'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-light-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
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>
</ul>
)
export default SwitchGroup;

View file

@ -0,0 +1,40 @@
import {Field} from "react-final-form";
import React from "react";
import Error from "./Error";
import {classNames} from "../../styles/utils";
interface Props {
name: string;
label?: string;
placeholder?: string;
className?: string;
required?: boolean;
}
const TextAreaWide: React.FC<Props> = ({name, label, placeholder, required, className}) => (
<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>
<label htmlFor={name} className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">
{label} {required && <span className="text-gray-500">*</span>}
</label>
</div>
<div className="sm:col-span-2">
<Field
name={name}
render={({input, meta}) => (
<textarea
{...input}
id={name}
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 focus:border-indigo-500 border-gray-300", "block w-full shadow-sm sm:text-sm rounded-md")}
placeholder={placeholder}
/>
)}
/>
<Error name={name} classNames="block text-red-500 mt-2"/>
</div>
</div>
)
export default TextAreaWide;

View file

@ -0,0 +1,45 @@
import { Field } from "react-final-form";
import React from "react";
import Error from "./Error";
import {classNames} from "../../styles/utils";
type COL_WIDTHS = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
interface Props {
name: string;
label?: string;
placeholder?: string;
columns?: COL_WIDTHS;
className?: string;
}
const TextField: React.FC<Props> = ({ name, label, placeholder, columns , className}) => (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
)}
>
{label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 uppercase tracking-wide">
{label}
</label>
)}
<Field
name={name}
render={({input, meta}) => (
<input
{...input}
id={name}
type="text"
className="mt-2 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
placeholder={placeholder}
/>
)}
/>
<div>
<Error name={name} classNames="text-red mt-2" />
</div>
</div>
)
export default TextField;

View file

@ -0,0 +1,41 @@
import {Field} from "react-final-form";
import React from "react";
import Error from "./Error";
import {classNames} from "../../styles/utils";
interface Props {
name: string;
label?: string;
placeholder?: string;
className?: string;
required?: boolean;
}
const TextFieldWide: React.FC<Props> = ({name, label, placeholder, required, className}) => (
<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>
<label htmlFor={name} className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">
{label} {required && <span className="text-gray-500">*</span>}
</label>
</div>
<div className="sm:col-span-2">
<Field
name={name}
render={({input, meta}) => (
<input
{...input}
id={name}
type="text"
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 focus:border-indigo-500 border-gray-300", "block w-full shadow-sm sm:text-sm rounded-md")}
placeholder={placeholder}
/>
)}
/>
<Error name={name} classNames="block text-red-500 mt-2"/>
</div>
</div>
)
export default TextFieldWide;

View file

@ -0,0 +1,6 @@
export { default as TextField } from "./TextField";
export { default as TextFieldWide } from "./TextFieldWide";
export { default as TextAreaWide } from "./TextAreaWide";
export { default as MultiSelectField } from "./MultiSelectField";
export { default as RadioFieldset } from "./RadioFieldset";
export { default as SwitchGroup } from "./SwitchGroup";

View file

@ -0,0 +1,92 @@
import {Fragment} from "react";
import {Dialog, Transition} from "@headlessui/react";
import {ExclamationIcon} from "@heroicons/react/solid";
interface props {
isOpen: boolean;
buttonRef: any;
toggle: any;
deleteAction: any;
title: string;
text: string;
}
const DeleteModal = ({ isOpen, buttonRef, toggle, deleteAction, title, text }: props) => (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={buttonRef}
open={isOpen}
onClose={toggle}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{title}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
{text}
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={deleteAction}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={toggle}
ref={buttonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
)
export default DeleteModal;

View file

@ -0,0 +1 @@
export { default as DeleteModal } from "./Delete";

View file

@ -0,0 +1,78 @@
import {DOWNLOAD_CLIENT_TYPES} from "./interfaces";
export const resolutions = [
"2160p",
"1080p",
"1080i",
"810p",
"720p",
"576p",
"480p",
"480i"
];
export const RESOLUTION_OPTIONS = resolutions.map(r => ({ value: r, label: r, key: r}));
export const codecs = [
"AVC",
"Remux",
"h.264 Remux",
"h.265 Remux",
"HEVC",
"VC-1",
"VC-1 Remux",
"h264",
"h265",
"x264",
"x265",
"XviD"
];
export const CODECS_OPTIONS = codecs.map(v => ({ value: v, label: v, key: v}));
export const sources = [
"BD5",
"BD9",
"BDr",
"BDRip",
"BluRay",
"BRRip",
"CAM",
"DVDR",
"DVDRip",
"DVDScr",
"HDCAM",
"HDDVD",
"HDDVDRip",
"HDTS",
"HDTV",
"Mixed",
"SiteRip",
"WEB-DL",
"Webrip"
];
export const SOURCES_OPTIONS = sources.map(v => ({ value: v, label: v, key: v}));
export const containers = [
"avi",
"mp4",
"mkv",
];
export const CONTAINER_OPTIONS = containers.map(v => ({ value: v, label: v, key: v}));
export interface radioFieldsetOption {
label: string;
description: string;
value: string;
}
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.Deluge},
];

View file

@ -0,0 +1,119 @@
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 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[];
seasons: string;
episodes: string;
match_releases: string;
except_releases: string;
match_release_groups: string;
except_release_groups: string;
match_categories: string;
except_categories: string;
match_tags: string;
except_tags: string;
match_uploaders: string;
except_uploaders: string;
actions: Action[];
indexers: Indexer[];
}
export interface Tracker {
id: number;
name: string;
type: string;
enabled: boolean;
}
export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE';
export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE'];
export type DownloadClientType = 'QBITTORRENT' | 'DELUGE';
// export const DOWNLOAD_CLIENT_TYPES: DownloadClientType[] = ['QBITTORRENT' , 'DELUGE'];
export enum DOWNLOAD_CLIENT_TYPES {
qBittorrent = 'QBITTORRENT',
Deluge = 'DELUGE'
}
export interface DownloadClient {
id: number;
name: string;
enabled: boolean;
type: DownloadClientType;
settings: object;
}
export interface Network {
id: number;
name: string;
enabled: boolean;
addr: string;
nick: string;
username: string;
realname: string;
pass: string;
sasl: SASL;
}
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;
}

View file

@ -0,0 +1,829 @@
import React, {Fragment, useEffect } from "react";
import {useMutation} from "react-query";
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {sleep} from "../../utils/utils";
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
import {classNames} from "../../styles/utils";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import APIClient from "../../api/APIClient";
interface radioFieldsetOption {
label: string;
description: string;
value: string;
}
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"},
];
interface props {
filter: Filter;
isOpen: boolean;
toggle: any;
clients: DownloadClient[];
}
function FilterActionAddForm({filter, isOpen, toggle, clients}: props) {
const mutation = useMutation((action: Action) => APIClient.actions.create(action), {
onSuccess: () => {
queryClient.invalidateQueries(['filter', filter.id]);
sleep(500).then(() => toggle())
}
})
useEffect(() => {
// console.log("render add action form", clients)
}, []);
const onSubmit = (data: any) => {
// TODO clear data depending on type
mutation.mutate(data)
};
const TypeForm = (values: any) => {
switch (values.type) {
case "TEST":
return (
<div className="p-4">
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true"/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Notice</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
The test action does nothing except to show if the filter works.
</p>
</div>
</div>
</div>
</div>
</div>
)
case "WATCH_FOLDER":
return (
<div className="">
<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>
<label
htmlFor="watch_folder"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Watch dir
</label>
</div>
<div className="sm:col-span-2">
<Field name="watch_folder">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Watch directory eg. /home/user/watch_folder"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
)
case "EXEC":
return (
<div className="">
<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>
<label
htmlFor="exec_cmd"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Program
</label>
</div>
<div className="sm:col-span-2">
<Field name="exec_cmd">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Path to program eg. /bin/test"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="exec_args"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Arguments
</label>
</div>
<div className="sm:col-span-2">
<Field name="exec_args">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Arguments eg. --test"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
)
case "QBITTORRENT":
return (
<div>
<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">
{/*// TODO change available clients to match only selected action type. eg qbittorrent or deluge*/}
<Field
name="client_id"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-sm font-medium text-gray-700">Client</Listbox.Label>
<div className="mt-1 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</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">
<SelectorIcon className="h-5 w-5 text-gray-400" 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 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"
>
{clients.filter((c) => c.type === values.type).map((client: any) => (
<Listbox.Option
key={client.id}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9'
)
}
value={client.id}
>
{({selected, active}) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
{client.name}
</span>
{selected ? (
<span
className={classNames(
active ? 'text-white' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)} />
</div>
<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>
<label
htmlFor="category"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Category
</label>
</div>
<div className="sm:col-span-2">
<Field name="category">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
// placeholder="Arguments eg. --test"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="tags"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Tags
</label>
</div>
<div className="sm:col-span-2">
<Field name="tags">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Comma separated eg. 4k,remux"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="save_path"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Save path. <br/><span className="text-gray-500">if left blank and category is selected it will use category path</span>
</label>
</div>
<div className="sm:col-span-2">
<Field name="save_path">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<div className="divide-y px-4 divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Limit speeds</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Limit download and upload speed for torrents in this filter. In KB/s.
</p>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_download_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit download speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_download_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_upload_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit upload speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_upload_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
</div>
</div>
)
case "DELUGE":
return (
<div>
{/*TODO choose client*/}
<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="client_id"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-sm font-medium text-gray-700">Client</Listbox.Label>
<div className="mt-1 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</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">
<SelectorIcon className="h-5 w-5 text-gray-400" 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 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"
>
{clients.filter((c) => c.type === values.type).map((client: any) => (
<Listbox.Option
key={client.id}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9'
)
}
value={client.id}
>
{({selected, active}) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
{client.name}
</span>
{selected ? (
<span
className={classNames(
active ? 'text-white' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)} />
</div>
<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>
<label
htmlFor="label"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Label
</label>
</div>
<div className="sm:col-span-2">
<Field name="label">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="save_path"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Save path
</label>
</div>
<div className="sm:col-span-2">
<Field name="save_path">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<div className="divide-y px-4 divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Limit speeds</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Limit download and upload speed for torrents in this filter. In KB/s.
</p>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_download_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit download speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_download_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_upload_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit upload speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_upload_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
</div>
</div>
)
default:
return (
<div className="p-4">
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true"/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Notice</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
The test action does nothing except to show if the filter works.
</p>
</div>
</div>
</div>
</div>
</div>
)
}
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
name: "",
enabled: false,
type: "TEST",
watch_folder: "",
exec_cmd: "",
exec_args: "",
category: "",
tags: "",
label: "",
save_path: "",
paused: false,
ignore_rules: false,
limit_upload_speed: 0,
limit_download_speed: 0,
filter_id: filter.id,
client_id: null,
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Add
action</Dialog.Title>
<p className="text-sm text-gray-500">
Add filter action.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
{/* Divider container */}
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Action name
</label>
</div>
<Field name="name">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<fieldset>
<div
className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
<div>
<legend
className="text-sm font-medium text-gray-900">Type
</legend>
</div>
<div className="space-y-5 sm:col-span-2">
<div className="space-y-5 sm:mt-0">
<Field
name="type"
type="radio"
render={({input}) => (
<RadioGroup value={values.type} onChange={input.onChange}>
<RadioGroup.Label className="sr-only">Privacy setting</RadioGroup.Label>
<div className="bg-white rounded-md -space-y-px">
{actionTypeOptions.map((setting, settingIdx) => (
<RadioGroup.Option
key={setting.value}
value={setting.value}
className={({checked}) =>
classNames(
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
settingIdx === actionTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200',
'relative border p-4 flex cursor-pointer focus:outline-none'
)
}
>
{({
active,
checked
}) => (
<Fragment>
<span
className={classNames(
checked ? 'bg-indigo-600 border-transparent' : 'bg-white border-gray-300',
active ? 'ring-2 ring-offset-2 ring-indigo-500' : '',
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center'
)}
aria-hidden="true"
>
<span className="rounded-full bg-white w-1.5 h-1.5"/>
</span>
<div
className="ml-3 flex flex-col">
<RadioGroup.Label
as="span"
className={classNames(checked ? 'text-indigo-900' : 'text-gray-900', 'block text-sm font-medium')}
>
{setting.label}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={classNames(checked ? 'text-indigo-700' : 'text-gray-500', 'block text-sm')}
>
{setting.description}
</RadioGroup.Description>
</div>
</Fragment>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
</div>
</div>
</div>
</fieldset>
{TypeForm(values)}
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default FilterActionAddForm;

View file

@ -0,0 +1,834 @@
import {Fragment, useEffect} from "react";
import {useMutation} from "react-query";
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {sleep} from "../../utils/utils";
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
import {classNames} from "../../styles/utils";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import APIClient from "../../api/APIClient";
interface radioFieldsetOption {
label: string;
description: string;
value: string;
}
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"},
];
interface props {
filter: Filter;
isOpen: boolean;
toggle: any;
clients: DownloadClient[];
action: Action;
}
function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props) {
const mutation = useMutation((action: Action) => APIClient.actions.update(action), {
onSuccess: () => {
console.log("add action");
queryClient.invalidateQueries(['filter', filter.id]);
sleep(1500)
toggle()
}
})
useEffect(() => {
console.log("render add action form", clients)
}, [clients]);
const onSubmit = (data: any) => {
// TODO clear data depending on type
console.log(data)
mutation.mutate(data)
};
const TypeForm = (values: any) => {
switch (values.type) {
case "TEST":
return (
<div className="p-4">
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true"/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Notice</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
The test action does nothing except to show if the filter works.
</p>
</div>
</div>
</div>
</div>
</div>
)
case "WATCH_FOLDER":
return (
<div className="">
<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>
<label
htmlFor="watch_folder"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Watch dir
</label>
</div>
<div className="sm:col-span-2">
<Field name="watch_folder">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Watch directory eg. /home/user/watch_folder"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
)
case "EXEC":
return (
<div className="">
<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>
<label
htmlFor="exec_cmd"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Program
</label>
</div>
<div className="sm:col-span-2">
<Field name="exec_cmd">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Path to program eg. /bin/test"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="exec_args"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Arguments
</label>
</div>
<div className="sm:col-span-2">
<Field name="exec_args">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Arguments eg. --test"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
)
case "QBITTORRENT":
return (
<div>
<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">
{/*// TODO change available clients to match only selected action type. eg qbittorrent or deluge*/}
<Field
name="client_id"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-sm font-medium text-gray-700">Client</Listbox.Label>
<div className="mt-1 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</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">
<SelectorIcon className="h-5 w-5 text-gray-400" 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 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"
>
{clients.filter((c) => c.type === values.type).map((client: any) => (
<Listbox.Option
key={client.id}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9'
)
}
value={client.id}
>
{({selected, active}) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
{client.name}
</span>
{selected ? (
<span
className={classNames(
active ? 'text-white' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)} />
</div>
<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>
<label
htmlFor="category"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Category
</label>
</div>
<div className="sm:col-span-2">
<Field name="category">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
// placeholder="Arguments eg. --test"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="tags"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Tags
</label>
</div>
<div className="sm:col-span-2">
<Field name="tags">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
placeholder="Comma separated eg. 4k,remux"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="save_path"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Save path. <br/><span className="text-gray-500">if left blank and category is selected it will use category path</span>
</label>
</div>
<div className="sm:col-span-2">
<Field name="save_path">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<div className="divide-y px-4 divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Limit speeds</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Limit download and upload speed for torrents in this filter. In KB/s.
</p>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_download_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit download speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_download_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_upload_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit upload speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_upload_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
</div>
</div>
)
case "DELUGE":
return (
<div>
{/*TODO choose client*/}
<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="client_id"
type="select"
render={({input}) => (
<Listbox value={input.value} onChange={input.onChange}>
{({open}) => (
<>
<Listbox.Label
className="block text-sm font-medium text-gray-700">Client</Listbox.Label>
<div className="mt-1 relative">
<Listbox.Button
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<span className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</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">
<SelectorIcon className="h-5 w-5 text-gray-400" 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 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"
>
{clients.filter((c) => c.type === values.type).map((client: any) => (
<Listbox.Option
key={client.id}
className={({active}) =>
classNames(
active ? 'text-white bg-indigo-600' : 'text-gray-900',
'cursor-default select-none relative py-2 pl-3 pr-9'
)
}
value={client.id}
>
{({selected, active}) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
{client.name}
</span>
{selected ? (
<span
className={classNames(
active ? 'text-white' : 'text-indigo-600',
'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>
</>
)}
</Listbox>
)} />
</div>
<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>
<label
htmlFor="label"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Label
</label>
</div>
<div className="sm:col-span-2">
<Field name="label">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<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>
<label
htmlFor="save_path"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Save path
</label>
</div>
<div className="sm:col-span-2">
<Field name="save_path">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
<div className="divide-y px-4 divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">Limit speeds</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Limit download and upload speed for torrents in this filter. In KB/s.
</p>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_download_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit download speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_download_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
<div className="pt-6 sm:pt-5">
<div className="space-y-1 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
<div>
<label
htmlFor="limit_upload_speed"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Limit upload speed
</label>
</div>
<div className="sm:col-span-2">
<Field name="limit_upload_speed">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
</div>
</div>
)
default:
return (
<div className="p-4">
<div className="rounded-md bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true"/>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">Notice</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
The test action does nothing except to show if the filter works.
</p>
</div>
</div>
</div>
</div>
</div>
)
}
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
name: "",
enabled: false,
type: "TEST",
watch_folder: "",
exec_cmd: "",
exec_args: "",
category: "",
tags: "",
label: "",
save_path: "",
paused: false,
ignore_rules: false,
limit_upload_speed: 0,
limit_download_speed: 0,
filter_id: filter.id,
client_id: null,
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Update action</Dialog.Title>
<p className="text-sm text-gray-500">
Add filter action.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
{/* Divider container */}
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Action name
</label>
</div>
<Field name="name">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<fieldset>
<div
className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
<div>
<legend
className="text-sm font-medium text-gray-900">Type
</legend>
</div>
<div className="space-y-5 sm:col-span-2">
<div className="space-y-5 sm:mt-0">
<Field
name="type"
type="radio"
render={({input}) => (
<RadioGroup value={values.type} onChange={input.onChange}>
<RadioGroup.Label className="sr-only">Privacy setting</RadioGroup.Label>
<div className="bg-white rounded-md -space-y-px">
{actionTypeOptions.map((setting, settingIdx) => (
<RadioGroup.Option
key={setting.value}
value={setting.value}
className={({checked}) =>
classNames(
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
settingIdx === actionTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200',
'relative border p-4 flex cursor-pointer focus:outline-none'
)
}
>
{({
active,
checked
}) => (
<Fragment>
<span
className={classNames(
checked ? 'bg-indigo-600 border-transparent' : 'bg-white border-gray-300',
active ? 'ring-2 ring-offset-2 ring-indigo-500' : '',
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center'
)}
aria-hidden="true"
>
<span className="rounded-full bg-white w-1.5 h-1.5"/>
</span>
<div
className="ml-3 flex flex-col">
<RadioGroup.Label
as="span"
className={classNames(checked ? 'text-indigo-900' : 'text-gray-900', 'block text-sm font-medium')}
>
{setting.label}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={classNames(checked ? 'text-indigo-700' : 'text-gray-500', 'block text-sm')}
>
{setting.description}
</RadioGroup.Description>
</div>
</Fragment>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
</div>
</div>
</div>
</fieldset>
{TypeForm(values)}
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default FilterActionUpdateForm;

View file

@ -0,0 +1,151 @@
import React, {Fragment, useEffect} from "react";
import {useMutation} from "react-query";
import {Filter} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {XIcon} from "@heroicons/react/solid";
import {Dialog, Transition} from "@headlessui/react";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import APIClient from "../../api/APIClient";
const required = (value: any) => (value ? undefined : 'Required')
function FilterAddForm({isOpen, toggle}: any) {
const mutation = useMutation((filter: Filter) => APIClient.filters.create(filter), {
onSuccess: () => {
queryClient.invalidateQueries('filter');
toggle()
}
})
useEffect(() => {
// console.log("render add action form")
}, []);
const onSubmit = (data: any) => {
mutation.mutate(data)
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
name: "",
enabled: false,
resolutions: [],
codecs: [],
sources: [],
containers: []
}}
// validate={validate}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll" onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Create
filter</Dialog.Title>
<p className="text-sm text-gray-500">
Add new filter.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
{/* Divider container */}
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Name
</label>
</div>
<Field name="name" validate={required}>
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create
</button>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default FilterAddForm;

11
web/src/forms/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { default as FilterAddForm } from "./filters/FilterAddForm";
export { default as FilterActionAddForm } from "./filters/FilterActionAddForm";
export { default as FilterActionUpdateForm } from "./filters/FilterActionUpdateForm";
export { default as DownloadClientAddForm } from "./settings/DownloadClientAddForm";
export { default as DownloadClientUpdateForm } from "./settings/DownloadClientUpdateForm";
export { default as IndexerAddForm } from "./settings/IndexerAddForm";
export { default as IndexerUpdateForm } from "./settings/IndexerUpdateForm";
export { default as IrcNetworkAddForm } from "./settings/IrcNetworkAddForm";

View file

@ -0,0 +1,412 @@
import {Fragment, useState} from "react";
import {useMutation} from "react-query";
import {DOWNLOAD_CLIENT_TYPES, DownloadClient} from "../../domain/interfaces";
import {Dialog, RadioGroup, Transition} from "@headlessui/react";
import {XIcon} from "@heroicons/react/solid";
import {classNames} from "../../styles/utils";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup} from "../../components/inputs";
import {queryClient} from "../../index";
import APIClient from "../../api/APIClient";
import {sleep} from "../../utils/utils";
interface radioFieldsetOption {
label: string;
description: string;
value: string;
}
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.Deluge},
];
function DownloadClientAddForm({isOpen, toggle}: any) {
const [isTesting, setIsTesting] = useState(false)
const [isSuccessfulTest, setIsSuccessfulTest] = useState(false)
const [isErrorTest, setIsErrorTest] = useState(false)
const mutation = useMutation((client: DownloadClient) => APIClient.download_clients.create(client), {
onSuccess: () => {
queryClient.invalidateQueries(['downloadClients']);
toggle()
}
})
const testClientMutation = useMutation((client: DownloadClient) => APIClient.download_clients.test(client), {
onMutate: () => {
setIsTesting(true)
setIsErrorTest(false)
setIsSuccessfulTest(false)
},
onSuccess: () => {
sleep(1000).then(() => {
setIsTesting(false)
setIsSuccessfulTest(true)
}).then(() => {
sleep(2500).then(() => {
setIsSuccessfulTest(false)
})
})
},
onError: (error) => {
setIsTesting(false)
setIsErrorTest(true)
sleep(2500).then(() => {
setIsErrorTest(false)
})
},
})
const onSubmit = (data: any) => {
mutation.mutate(data)
};
const testClient = (data: any) => {
testClientMutation.mutate(data)
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
name: "",
type: DOWNLOAD_CLIENT_TYPES.qBittorrent,
enabled: true,
host: "",
port: 10000,
ssl: false,
username: "",
password: "",
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Add
client</Dialog.Title>
<p className="text-sm text-gray-500">
Add download client.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Name
</label>
</div>
<Field name="name">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="enabled" label="Enabled"/>
</div>
<fieldset>
<div
className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
<div>
<legend
className="text-sm font-medium text-gray-900">Type
</legend>
</div>
<div className="space-y-5 sm:col-span-2">
<div className="space-y-5 sm:mt-0">
<Field
name="type"
type="radio"
render={({input}) => (
<RadioGroup value={values.type}
onChange={input.onChange}>
<RadioGroup.Label
className="sr-only">Privacy
setting</RadioGroup.Label>
<div
className="bg-white rounded-md -space-y-px">
{downloadClientTypeOptions.map((setting, settingIdx) => (
<RadioGroup.Option
key={setting.value}
value={setting.value}
className={({checked}) =>
classNames(
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
settingIdx === downloadClientTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200',
'relative border p-4 flex cursor-pointer focus:outline-none'
)
}
>
{({
active,
checked
}) => (
<Fragment>
<span
className={classNames(
checked ? 'bg-indigo-600 border-transparent' : 'bg-white border-gray-300',
active ? 'ring-2 ring-offset-2 ring-indigo-500' : '',
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center'
)}
aria-hidden="true"
>
<span
className="rounded-full bg-white w-1.5 h-1.5"/>
</span>
<div
className="ml-3 flex flex-col">
<RadioGroup.Label
as="span"
className={classNames(checked ? 'text-indigo-900' : 'text-gray-900', 'block text-sm font-medium')}
>
{setting.label}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={classNames(checked ? 'text-indigo-700' : 'text-gray-500', 'block text-sm')}
>
{setting.description}
</RadioGroup.Description>
</div>
</Fragment>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
</div>
</div>
</div>
</fieldset>
<div>
<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>
<label
htmlFor="host"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Host
</label>
</div>
<Field name="host">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<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>
<label
htmlFor="port"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Port
</label>
</div>
<Field name="port" parse={(v) => v && parseInt(v, 10)}>
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="ssl" label="SSL"/>
</div>
<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>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Username
</label>
</div>
<Field name="username">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<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>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Password
</label>
</div>
<Field name="password">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className={classNames(isSuccessfulTest ? "text-green-500 border-green-500 bg-green-50" : (isErrorTest ? "text-red-500 border-red-500 bg-red-50" : "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:border-rose-700 active:bg-rose-700"), isTesting ? "cursor-not-allowed" : "", "mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150")}
disabled={isTesting}
onClick={() => testClient(values)}
>
{isTesting ?
<svg
className="animate-spin h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12"
r="10" stroke="currentColor"
strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
: (isSuccessfulTest ? "OK!" : (isErrorTest ? "ERROR" : "Test"))
}
</button>
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create
</button>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default DownloadClientAddForm;

View file

@ -0,0 +1,506 @@
import {Fragment, useRef, useState} from "react";
import {useToggle} from "../../hooks/hooks";
import {useMutation} from "react-query";
import {DownloadClient} from "../../domain/interfaces";
import {queryClient} from "../../index";
import {Dialog, RadioGroup, Transition} from "@headlessui/react";
import {ExclamationIcon, XIcon} from "@heroicons/react/solid";
import {classNames} from "../../styles/utils";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup} from "../../components/inputs";
import {DownloadClientTypeOptions} from "../../domain/constants";
import APIClient from "../../api/APIClient";
import {sleep} from "../../utils/utils";
function DownloadClientUpdateForm({client, isOpen, toggle}: any) {
const [isTesting, setIsTesting] = useState(false)
const [isSuccessfulTest, setIsSuccessfulTest] = useState(false)
const [isErrorTest, setIsErrorTest] = useState(false)
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
const mutation = useMutation((client: DownloadClient) => APIClient.download_clients.update(client), {
onSuccess: () => {
queryClient.invalidateQueries(['downloadClients']);
toggle()
}
})
const deleteMutation = useMutation((clientID: number) => APIClient.download_clients.delete(clientID), {
onSuccess: () => {
queryClient.invalidateQueries();
toggleDeleteModal()
}
})
const testClientMutation = useMutation((client: DownloadClient) => APIClient.download_clients.test(client), {
onMutate: () => {
setIsTesting(true)
setIsErrorTest(false)
setIsSuccessfulTest(false)
},
onSuccess: () => {
sleep(1000).then(() => {
setIsTesting(false)
setIsSuccessfulTest(true)
}).then(() => {
sleep(2500).then(() => {
setIsSuccessfulTest(false)
})
})
},
onError: (error) => {
setIsTesting(false)
setIsErrorTest(true)
sleep(2500).then(() => {
setIsErrorTest(false)
})
},
})
const onSubmit = (data: any) => {
mutation.mutate(data)
};
const cancelButtonRef = useRef(null)
const cancelModalButtonRef = useRef(null)
const deleteAction = () => {
deleteMutation.mutate(client.id)
}
const testClient = (data: any) => {
testClientMutation.mutate(data)
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}
initialFocus={cancelButtonRef}>
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={cancelModalButtonRef}
open={deleteModalIsOpen}
onClose={toggleDeleteModal}
>
<div
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true"/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3"
className="text-lg leading-6 font-medium text-gray-900">
Remove client
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to remove this client?
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={deleteAction}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={toggleDeleteModal}
ref={cancelModalButtonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
id: client.id,
name: client.name,
type: client.type,
enabled: client.enabled,
host: client.host,
port: client.port,
ssl: client.ssl,
username: client.username,
password: client.password
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Edit
client</Dialog.Title>
<p className="text-sm text-gray-500">
Edit download client settings.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Name
</label>
</div>
<Field name="name">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="enabled" label="Enabled"/>
</div>
<fieldset>
<div
className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
<div>
<legend
className="text-sm font-medium text-gray-900">Type
</legend>
</div>
<div className="space-y-5 sm:col-span-2">
<div className="space-y-5 sm:mt-0">
<Field
name="type"
type="radio"
render={({input}) => (
<RadioGroup value={values.type}
onChange={input.onChange}>
<RadioGroup.Label
className="sr-only">Privacy
setting</RadioGroup.Label>
<div
className="bg-white rounded-md -space-y-px">
{DownloadClientTypeOptions.map((setting, settingIdx) => (
<RadioGroup.Option
key={setting.value}
value={setting.value}
className={({checked}) =>
classNames(
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
settingIdx === DownloadClientTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200',
'relative border p-4 flex cursor-pointer focus:outline-none'
)
}
>
{({
active,
checked
}) => (
<Fragment>
<span
className={classNames(
checked ? 'bg-indigo-600 border-transparent' : 'bg-white border-gray-300',
active ? 'ring-2 ring-offset-2 ring-indigo-500' : '',
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center'
)}
aria-hidden="true"
>
<span
className="rounded-full bg-white w-1.5 h-1.5"/>
</span>
<div
className="ml-3 flex flex-col">
<RadioGroup.Label
as="span"
className={classNames(checked ? 'text-indigo-900' : 'text-gray-900', 'block text-sm font-medium')}
>
{setting.label}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={classNames(checked ? 'text-indigo-700' : 'text-gray-500', 'block text-sm')}
>
{setting.description}
</RadioGroup.Description>
</div>
</Fragment>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
)}
/>
</div>
</div>
</div>
</fieldset>
<div>
<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>
<label
htmlFor="host"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Host
</label>
</div>
<Field name="host">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<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>
<label
htmlFor="port"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Port
</label>
</div>
<Field name="port" parse={(v) => v && parseInt(v, 10)}>
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="number"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="ssl" label="SSL"/>
</div>
<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>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Username
</label>
</div>
<Field name="username">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<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>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Password
</label>
</div>
<Field name="password">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div className="flex">
<button
type="button"
className={classNames(isSuccessfulTest ? "text-green-500 border-green-500 bg-green-50" : (isErrorTest ? "text-red-500 border-red-500 bg-red-50" : "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:border-rose-700 active:bg-rose-700"), isTesting ? "cursor-not-allowed" : "", "mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150")}
disabled={isTesting}
onClick={() => testClient(values)}
>
{isTesting ?
<svg
className="animate-spin h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12"
r="10" stroke="currentColor"
strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
: (isSuccessfulTest ? "OK!" : (isErrorTest ? "ERROR" : "Test"))
}
</button>
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
type="submit"
className="ml-4 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default DownloadClientUpdateForm;

View file

@ -0,0 +1,241 @@
import React, {Fragment, useEffect} from "react";
import {useMutation, useQuery} from "react-query";
import {Indexer} from "../../domain/interfaces";
import {sleep} from "../../utils/utils";
import {XIcon} from "@heroicons/react/solid";
import {Dialog, Transition} from "@headlessui/react";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import Select from "react-select";
import {queryClient} from "../../index";
import { SwitchGroup } from "../../components/inputs";
import APIClient from "../../api/APIClient";
interface props {
isOpen: boolean;
toggle: any;
}
function IndexerAddForm({isOpen, toggle}: props) {
const {data} = useQuery<any[], Error>('indexerSchema', APIClient.indexers.getSchema,
{
enabled: isOpen,
refetchOnWindowFocus: false
}
)
const mutation = useMutation((indexer: Indexer) => APIClient.indexers.create(indexer), {
onSuccess: () => {
queryClient.invalidateQueries(['indexer']);
sleep(1500)
toggle()
}
})
const onSubmit = (data: any) => {
mutation.mutate(data)
};
const renderSettingFields = (indexer: string) => {
if (indexer !== "") {
// let ind = data.find(i => i.implementation_name === indexer)
let ind = data && data.find(i => i.identifier === indexer)
return (
<div key="opt">
{ind && ind.settings && ind.settings.map((f: any, idx: number) => {
switch (f.type) {
case "text":
return (
<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" key={idx}>
<div>
<label
htmlFor={f.name}
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
{f.label}
</label>
</div>
<div className="sm:col-span-2">
<Field name={"settings."+f.name}>
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
)
}
})}
</div>
)
}
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
name: "",
enabled: true,
identifier: "",
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Add
indexer</Dialog.Title>
<p className="text-sm text-gray-500">
Add indexer.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
{/* Divider container */}
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Name
</label>
</div>
<Field name="name">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="enabled" label="Enabled" />
</div>
<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>
<label
htmlFor="identifier"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Indexer
</label>
</div>
<div className="sm:col-span-2">
<Field
name="identifier"
parse={val => val && val.value}
format={val => data && data.find((o: any) => o.value === val)}
render={({input, meta}) => (
<React.Fragment>
<Select {...input}
isClearable={true}
placeholder="Choose an indexer"
options={data && data.sort((a,b): any => a.name.localeCompare(b.name)).map(v => ({
label: v.name,
value: v.identifier
// value: v.implementation_name
}))}/>
{/*<Error name={input.name} classNames="text-red mt-2 block" />*/}
</React.Fragment>
)}
/>
</div>
</div>
{renderSettingFields(values.identifier)}
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default IndexerAddForm;

View file

@ -0,0 +1,307 @@
import {Fragment, useRef} from "react";
import {useMutation } from "react-query";
import {Indexer} from "../../domain/interfaces";
import {sleep} from "../../utils/utils";
import {ExclamationIcon, XIcon} from "@heroicons/react/solid";
import {Dialog, Transition} from "@headlessui/react";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import { SwitchGroup } from "../../components/inputs";
import {queryClient} from "../../index";
import {useToggle} from "../../hooks/hooks";
import APIClient from "../../api/APIClient";
interface props {
isOpen: boolean;
toggle: any;
indexer: Indexer;
}
function IndexerUpdateForm({isOpen, toggle, indexer}: props) {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
const mutation = useMutation((indexer: Indexer) => APIClient.indexers.update(indexer), {
onSuccess: () => {
queryClient.invalidateQueries(['indexer']);
sleep(1500)
toggle()
}
})
const deleteMutation = useMutation((id: number) => APIClient.indexers.delete(id), {
onSuccess: () => {
queryClient.invalidateQueries(['indexer']);
}
})
const cancelModalButtonRef = useRef(null)
const onSubmit = (data: any) => {
// TODO clear data depending on type
mutation.mutate(data)
};
const deleteAction = () => {
deleteMutation.mutate(indexer.id)
}
const renderSettingFields = (settings: any[]) => {
if (settings !== []) {
return (
<div key="opt">
{settings && settings.map((f: any, idx: number) => {
switch (f.type) {
case "text":
return (
<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" key={idx}>
<div>
<label
htmlFor={f.name}
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
{f.label}
</label>
</div>
<div className="sm:col-span-2">
<Field name={"settings."+f.name}>
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
</div>
)
}
})}
</div>
)
}
}
// const setss = indexer.settings.reduce((o: any, obj: any) => ({ ...o, [obj.name]: obj.value }), {})
// console.log("setts", setss)
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={cancelModalButtonRef}
open={deleteModalIsOpen}
onClose={toggleDeleteModal}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
Remove indexer
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to remove this indexer?
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={deleteAction}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={toggleDeleteModal}
ref={cancelModalButtonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
id: indexer.id,
name: indexer.name,
enabled: indexer.enabled,
identifier: indexer.identifier,
settings: indexer.settings.reduce((o: any, obj: any) => ({ ...o, [obj.name]: obj.value }), {}),
}}
onSubmit={onSubmit}
>
{({handleSubmit, values}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Update
indexer</Dialog.Title>
<p className="text-sm text-gray-500">
Update indexer.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
{/* Divider container */}
<div
className="py-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">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
>
Name
</label>
</div>
<Field name="name">
{({input, meta}) => (
<div className="sm:col-span-2">
<input
type="text"
{...input}
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
/>
{meta.touched && meta.error &&
<span>{meta.error}</span>}
</div>
)}
</Field>
</div>
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="enabled" label="Enabled" />
</div>
{renderSettingFields(indexer.settings)}
</div>
</div>
<div className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div>
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
type="submit"
className="ml-4 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default IndexerUpdateForm;

View file

@ -0,0 +1,321 @@
import {Fragment} from "react";
import {useMutation} from "react-query";
import {Network} from "../../domain/interfaces";
import {Dialog, Transition} from "@headlessui/react";
import {XIcon} from "@heroicons/react/solid";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
import {queryClient} from "../../index";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
import {classNames} from "../../styles/utils";
import APIClient from "../../api/APIClient";
// interface radioFieldsetOption {
// label: string;
// description: string;
// value: string;
// }
// const saslTypeOptions: radioFieldsetOption[] = [
// {label: "None", description: "None", value: ""},
// {label: "Plain", description: "SASL plain", value: "PLAIN"},
// {label: "NickServ", description: "/NS identify", value: "NICKSERV"},
// ];
function IrcNetworkAddForm({isOpen, toggle}: any) {
const mutation = useMutation((network: Network) => APIClient.irc.createNetwork(network), {
onSuccess: data => {
queryClient.invalidateQueries(['networks']);
toggle()
}
})
const onSubmit = (data: any) => {
console.log(data)
// easy way to split textarea lines into array of strings for each newline.
// parse on the field didn't really work.
let cmds = data.connect_commands && data.connect_commands.length > 0 ? data.connect_commands.replace(/\r\n/g,"\n").split("\n") : [];
data.connect_commands = cmds
console.log("formated", data)
mutation.mutate(data)
};
const validate = (values: any) => {
const errors = {} as any;
if (!values.name) {
errors.name = "Required";
}
if (!values.addr) {
errors.addr = "Required";
}
if (!values.nick) {
errors.nick = "Required";
}
return errors;
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
name: "",
enabled: true,
addr: "",
tls: false,
nick: "",
pass: "",
// connect_commands: "",
// sasl: {
// mechanism: "",
// plain: {
// username: "",
// password: "",
// }
// },
}}
mutators={{
...arrayMutators
}}
validate={validate}
onSubmit={onSubmit}
>
{({handleSubmit, values, pristine, invalid}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Add
network</Dialog.Title>
<p className="text-sm text-gray-500">
Add irc network.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<TextFieldWide name="name" label="Name" placeholder="Name" required={true} />
<div className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<div
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="enabled" label="Enabled"/>
</div>
<div>
<TextFieldWide name="addr" label="Address" placeholder="Address:port eg irc.server.net:6697" required={true} />
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="tls" label="TLS"/>
</div>
<TextFieldWide name="nick" label="Nick" placeholder="Nick" required={true} />
<TextFieldWide name="password" label="Password" placeholder="Network password" />
<TextAreaWide name="connect_commands" label="Connect commands" placeholder="/msg test this" />
{/* <Field*/}
{/* name="sasl.mechanism"*/}
{/* type="select"*/}
{/* render={({input}) => (*/}
{/* <Listbox value={input.value} onChange={input.onChange}>*/}
{/* {({open}) => (*/}
{/* <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>*/}
{/* <Listbox.Label className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">SASL / auth</Listbox.Label>*/}
{/* </div>*/}
{/* <div className="sm:col-span-2 relative">*/}
{/* <Listbox.Button*/}
{/* className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">*/}
{/* <span className="block truncate">{input.value ? saslTypeOptions.find(c => c.value === input.value)!.label : "Choose auth method"}</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" 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 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"*/}
{/* >*/}
{/* {saslTypeOptions.map((opt: any) => (*/}
{/* <Listbox.Option*/}
{/* key={opt.value}*/}
{/* className={({active}) =>*/}
{/* classNames(*/}
{/* active ? 'text-white bg-indigo-600' : 'text-gray-900',*/}
{/* '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' : 'text-indigo-600',*/}
{/* '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>*/}
{/* )} />*/}
</div>
</div>
<div className="p-6">
<FieldArray name="channels">
{({ fields }) => (
<div className="flex flex-col border-2 border-dashed p-4">
{fields && (fields.length as any) > 0 ? (
fields.map((name, index) => (
<div key={name} className="flex justify-between">
<div className="flex">
<Field
name={`${name}.name`}
component="input"
type="text"
placeholder="#Channel"
className="mr-4 focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 block w-full shadow-sm sm:text-sm rounded-md"
/>
<Field
name={`${name}.password`}
component="input"
type="text"
placeholder="Password"
className="focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 block w-full shadow-sm sm:text-sm rounded-md"
/>
</div>
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={() => fields.remove(index)}
>
<span className="sr-only">Remove</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
))
) : (
<span className="text-center text-sm text-grey-darker">
No channels!
</span>
)}
<button
type="button"
className="border my-4 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded self-center text-center"
onClick={() => fields.push({ name: "", password: "" })}
>
Add Channel
</button>
</div>
)}
</FieldArray>
</div>
</div>
<div
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
type="submit"
disabled={pristine || invalid}
// 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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className={classNames(pristine || invalid ? "bg-indigo-300" : "bg-indigo-600 hover:bg-indigo-700","inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500")}
>
Create
</button>
</div>
</div>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default IrcNetworkAddForm;

View file

@ -0,0 +1,383 @@
import {Fragment, useEffect, useRef} from "react";
import {useMutation} from "react-query";
import {Network} from "../../domain/interfaces";
import {Dialog, Transition} from "@headlessui/react";
import {XIcon} from "@heroicons/react/solid";
import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
import {queryClient} from "../../index";
import arrayMutators from "final-form-arrays";
import { FieldArray } from "react-final-form-arrays";
import {classNames} from "../../styles/utils";
import {useToggle} from "../../hooks/hooks";
import {DeleteModal} from "../../components/modals";
import APIClient from "../../api/APIClient";
// interface radioFieldsetOption {
// label: string;
// description: string;
// value: string;
// }
//
// const saslTypeOptions: radioFieldsetOption[] = [
// {label: "None", description: "None", value: ""},
// {label: "Plain", description: "SASL plain", value: "PLAIN"},
// {label: "NickServ", description: "/NS identify", value: "NICKSERV"},
// ];
function IrcNetworkUpdateForm({isOpen, toggle, network}: any) {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
const mutation = useMutation((network: Network) => APIClient.irc.updateNetwork(network), {
onSuccess: () => {
queryClient.invalidateQueries(['networks']);
toggle()
}
})
const deleteMutation = useMutation((id: number) => APIClient.irc.deleteNetwork(id), {
onSuccess: () => {
queryClient.invalidateQueries(['networks']);
toggle()
}
})
useEffect(() => {
console.log("render add network form")
}, []);
const onSubmit = (data: any) => {
console.log(data)
// easy way to split textarea lines into array of strings for each newline.
// parse on the field didn't really work.
// TODO fix connect_commands on network update
// let cmds = data.connect_commands && data.connect_commands.length > 0 ? data.connect_commands.replace(/\r\n/g,"\n").split("\n") : [];
// data.connect_commands = cmds
// console.log("formatted", data)
mutation.mutate(data)
};
const validate = (values: any) => {
const errors = {} as any;
if (!values.name) {
errors.name = "Required";
}
if (!values.addr) {
errors.addr = "Required";
}
if (!values.nick) {
errors.nick = "Required";
}
return errors;
}
const cancelModalButtonRef = useRef(null)
const deleteAction = () => {
deleteMutation.mutate(network.id)
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<DeleteModal
isOpen={deleteModalIsOpen}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}
title="Remove network"
text="Are you sure you want to remove this network and channels? This action cannot be undone."
/>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl">
<Form
initialValues={{
id: network.id,
name: network.name,
enabled: network.enabled,
addr: network.addr,
tls: network.tls,
nick: network.nick,
pass: network.pass,
connect_commands: network.connect_commands,
sasl: network.sasl,
// sasl: {
// mechanism: network.sasl.mechanism,
// plain: {
// username: network.sasl.plain.username,
// password: network.sasl.plain.password,
// }
// },
channels: network.channels
}}
mutators={{
...arrayMutators
}}
validate={validate}
onSubmit={onSubmit}
>
{({handleSubmit, values, pristine, invalid}) => {
return (
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
onSubmit={handleSubmit}>
<div className="flex-1">
{/* Header */}
<div className="px-4 py-6 bg-gray-50 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title
className="text-lg font-medium text-gray-900">Update network</Dialog.Title>
<p className="text-sm text-gray-500">
Update irc network.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<TextFieldWide name="name" label="Name" placeholder="Name" required={true} />
<div className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<div
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="enabled" label="Enabled"/>
</div>
<div>
<TextFieldWide name="addr" label="Address" placeholder="Address:port eg irc.server.net:6697" required={true} />
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="tls" label="TLS"/>
</div>
<TextFieldWide name="nick" label="Nick" placeholder="Nick" required={true} />
<TextFieldWide name="password" label="Password" placeholder="Network password" />
<TextAreaWide name="connect_commands" label="Connect commands" placeholder="/msg test this" />
{/* <Field*/}
{/* name="sasl.mechanism"*/}
{/* type="select"*/}
{/* render={({input}) => (*/}
{/* <Listbox value={input.value} onChange={input.onChange}>*/}
{/* {({open}) => (*/}
{/* <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>*/}
{/* <Listbox.Label className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">SASL / auth</Listbox.Label>*/}
{/* </div>*/}
{/* <div className="sm:col-span-2 relative">*/}
{/* <Listbox.Button*/}
{/* className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">*/}
{/* <span className="block truncate">{input.value ? saslTypeOptions.find(c => c.value === input.value)!.label : "Choose a auth type"}</span>*/}
{/* /!*<span className="block truncate">Choose a auth type</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" 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 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"*/}
{/* >*/}
{/* {saslTypeOptions.map((opt: any) => (*/}
{/* <Listbox.Option*/}
{/* key={opt.value}*/}
{/* className={({active}) =>*/}
{/* classNames(*/}
{/* active ? 'text-white bg-indigo-600' : 'text-gray-900',*/}
{/* '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' : 'text-indigo-600',*/}
{/* '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>*/}
{/* )} />*/}
</div>
</div>
<div className="p-6">
<FieldArray name="channels">
{({ fields }) => (
<div className="flex flex-col border-2 border-dashed p-4">
{fields && (fields.length as any) > 0 ? (
fields.map((name, index) => (
<div key={name} className="flex justify-between">
<div className="flex">
<Field
name={`${name}.name`}
component="input"
type="text"
placeholder="#Channel"
className="focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 block w-full shadow-sm sm:text-sm rounded-md"
/>
<Field
name={`${name}.password`}
component="input"
type="text"
placeholder="Password"
className="focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 block w-full shadow-sm sm:text-sm rounded-md"
/>
</div>
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={() => fields.remove(index)}
>
<span className="sr-only">Remove</span>
<XIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
))
) : (
<span className="text-center text-sm text-grey-darker">
No channels!
</span>
)}
<button
type="button"
className="border my-4 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded self-center text-center"
onClick={() => fields.push({ name: "", password: "" })}
>
Add Channel
</button>
</div>
)}
</FieldArray>
</div>
</div>
<div className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
<div className="space-x-3 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div>
<button
type="button"
className="mr-4 bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"
onClick={toggle}
>
Cancel
</button>
<button
type="submit"
disabled={pristine || invalid}
className={classNames(pristine || invalid ? "bg-indigo-300" : "bg-indigo-600 hover:bg-indigo-700","inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500")}
>
Save
</button>
</div>
</div>
</div>
{/*<div*/}
{/* className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">*/}
{/* <div className="space-x-3 flex justify-end">*/}
{/* <button*/}
{/* type="button"*/}
{/* className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm 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"*/}
{/* onClick={toggle}*/}
{/* >*/}
{/* Cancel*/}
{/* </button>*/}
{/* <button*/}
{/* type="submit"*/}
{/* disabled={pristine || invalid}*/}
{/* className={classNames(pristine || invalid ? "bg-indigo-300" : "bg-indigo-600 hover:bg-indigo-700","inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500")}*/}
{/* >*/}
{/* Save*/}
{/* </button>*/}
{/* </div>*/}
{/*</div>*/}
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
)
}
export default IrcNetworkUpdateForm;

9
web/src/hooks/hooks.ts Normal file
View file

@ -0,0 +1,9 @@
import React from "react";
export function useToggle(initialValue: boolean = false): [boolean, () => void] {
const [value, setValue] = React.useState<boolean>(initialValue);
const toggle = React.useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle];
}

17
web/src/index.css Normal file
View file

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

61
web/src/index.tsx Normal file
View file

@ -0,0 +1,61 @@
import React, {useEffect, useState} from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import reportWebVitals from './reportWebVitals';
import Base from "./screens/Base";
import {BrowserRouter as Router,} from "react-router-dom";
import {ReactQueryDevtools} from 'react-query/devtools'
import {QueryClient, QueryClientProvider} from 'react-query'
import {RecoilRoot, useRecoilState} from 'recoil';
import {configState} from "./state/state";
import APIClient from "./api/APIClient";
import {APP} from "./domain/interfaces";
declare global {
interface Window { APP: APP; }
}
window.APP = window.APP || {};
export const queryClient = new QueryClient()
const ConfigWrapper = () => {
const [config, setConfig] = useRecoilState(configState)
const [loading, setLoading] = useState(true)
useEffect(() => {
APIClient.config.get().then(res => {
setConfig(res)
setLoading(false)
})
}, [setConfig])
return (
<QueryClientProvider client={queryClient}>
{loading ? null : (
<Router basename={config.base_url}>
<Base/>
</Router>
)}
<ReactQueryDevtools initialIsOpen={false}/>
</QueryClientProvider>
)
};
ReactDOM.render(
<React.StrictMode>
<RecoilRoot>
<ConfigWrapper/>
</RecoilRoot>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
web/src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,15 @@
import { 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;

202
web/src/screens/Base.tsx Normal file
View file

@ -0,0 +1,202 @@
import {Fragment} from 'react'
import {Disclosure, Menu, Transition} from '@headlessui/react'
import {BellIcon, ChevronDownIcon, MenuIcon, XIcon} from '@heroicons/react/outline'
import {NavLink,Link, Route, Switch} from "react-router-dom";
import Settings from "./Settings";
import { Dashboard } from "./Dashboard";
import { FilterDetails, Filters} from "./Filters";
const profile = ['Settings', 'Sign out']
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ')
}
export default function Base() {
const nav = [{name: 'Dashboard', path: "/"}, {name: 'Filters', path: "/filters"}, {name: "Settings", path: "/settings"}]
return (
<div>
<div className="">
<Disclosure as="nav" className="bg-gray-800 pb-48">
{({open}) => (
<>
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-gray-700">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<div className="flex items-center">
<div className="hidden md:block">
<div className="flex items-baseline space-x-4">
{nav.map((item, itemIdx) =>
<NavLink
key={itemIdx}
to={item.path}
exact={true}
activeClassName="bg-gray-900 text-white "
className="text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
>
{item.name}
</NavLink>
)}
</div>
</div>
</div>
{/* <div className="hidden md:block">*/}
{/* <div className="ml-4 flex items-center md:ml-6">*/}
{/* <button*/}
{/* className="bg-gray-800 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
{/* <span className="sr-only">View notifications</span>*/}
{/* <BellIcon className="h-6 w-6" aria-hidden="true"/>*/}
{/* </button>*/}
{/* <Menu as="div" className="ml-3 relative">*/}
{/* {({open}) => (*/}
{/* <>*/}
{/* <div>*/}
{/* <Menu.Button*/}
{/* className="max-w-xs bg-gray-800 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 lg:block">*/}
{/* <span className="sr-only">Open user menu for </span>User*/}
{/*</span>*/}
{/* <ChevronDownIcon*/}
{/* className="hidden flex-shrink-0 ml-1 h-5 w-5 text-gray-400 lg:block"*/}
{/* aria-hidden="true"*/}
{/* />*/}
{/* </Menu.Button>*/}
{/* </div>*/}
{/* <Transition*/}
{/* show={open}*/}
{/* as={Fragment}*/}
{/* enter="transition ease-out duration-100"*/}
{/* enterFrom="transform opacity-0 scale-95"*/}
{/* enterTo="transform opacity-100 scale-100"*/}
{/* leave="transition ease-in duration-75"*/}
{/* leaveFrom="transform opacity-100 scale-100"*/}
{/* leaveTo="transform opacity-0 scale-95"*/}
{/* >*/}
{/* <Menu.Items*/}
{/* static*/}
{/* className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"*/}
{/* >*/}
{/* <Menu.Item>*/}
{/* {({active}) => (*/}
{/* <Link*/}
{/* to="settings"*/}
{/* className={classNames(*/}
{/* active ? 'bg-gray-100' : '',*/}
{/* 'block px-4 py-2 text-sm text-gray-700'*/}
{/* )}*/}
{/* >*/}
{/* Settings*/}
{/* </Link>*/}
{/* )}*/}
{/* </Menu.Item>*/}
{/* <Menu.Item>*/}
{/* {({active}) => (*/}
{/* <Link*/}
{/* to="logout"*/}
{/* className={classNames(*/}
{/* active ? 'bg-gray-100' : '',*/}
{/* 'block px-4 py-2 text-sm text-gray-700'*/}
{/* )}*/}
{/* >*/}
{/* Logout*/}
{/* </Link>*/}
{/* )}*/}
{/* </Menu.Item>*/}
{/* </Menu.Items>*/}
{/* </Transition>*/}
{/* </>*/}
{/* )}*/}
{/* </Menu>*/}
{/* </div>*/}
{/* </div>*/}
<div className="-mr-2 flex md:hidden">
{/* Mobile menu button */}
<Disclosure.Button
className="bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<span className="sr-only">Open main menu</span>
{open ? (
<XIcon className="block h-6 w-6" aria-hidden="true"/>
) : (
<MenuIcon className="block h-6 w-6" aria-hidden="true"/>
)}
</Disclosure.Button>
</div>
</div>
</div>
</div>
<Disclosure.Panel className="border-b border-gray-700 md:hidden">
<div className="px-2 py-3 space-y-1 sm:px-3">
{nav.map((item, itemIdx) =>
itemIdx === 0 ? (
<Fragment key={item.path}>
{/* Current: "bg-gray-900 text-white", Default: "text-gray-300 hover:bg-gray-700 hover:text-white" */}
<Link to={item.path}
className="bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium">
{item.name}
</Link>
</Fragment>
) : (
<Link
key={item.path}
to={item.path}
className="text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
>
{item.name}
</Link>
)
)}
</div>
<div className="pt-4 pb-3 border-t border-gray-700">
<div className="flex items-center px-5">
<div>
<div className="text-base font-medium leading-none text-white">User</div>
{/*<div className="text-sm font-medium leading-none text-gray-400">tom@example.com</div>*/}
</div>
<button
className="ml-auto bg-gray-800 flex-shrink-0 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
<span className="sr-only">View notifications</span>
<BellIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
<div className="mt-3 px-2 space-y-1">
{profile.map((item) => (
<Link
key={item}
to={item}
className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700"
>
{item}
</Link>
))}
</div>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
<Switch>
<Route path="/settings">
<Settings/>
</Route>
<Route exact={true} path="/filters">
<Filters/>
</Route>
<Route path="/filters/:filterId">
<FilterDetails />
</Route>
<Route path="/">
<Dashboard />
</Route>
</Switch>
</div>
</div>
)
}

View file

@ -0,0 +1,18 @@
export function Dashboard() {
return (
<main className="-mt-48">
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white capitalize">Dashboard</h1>
</div>
</header>
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
<div className="border-4 border-dashed border-gray-200 rounded-lg h-96" />
</div>
</div>
</main>
)
}

833
web/src/screens/Filters.tsx Normal file
View file

@ -0,0 +1,833 @@
import React, {Fragment, useRef, useState} from "react";
import {Dialog, Switch, Transition} from "@headlessui/react";
import {ChevronRightIcon, ExclamationIcon, } from '@heroicons/react/solid'
import {EmptyListState} from "../components/EmptyListState";
import {
Link,
NavLink,
Route,
Switch as RouteSwitch,
useHistory,
useLocation,
useParams,
useRouteMatch
} from "react-router-dom";
import {FilterActionList} from "../components/FilterActionList";
import {DownloadClient, Filter, Indexer} from "../domain/interfaces";
import {useToggle} from "../hooks/hooks";
import {useMutation, useQuery} from "react-query";
import {queryClient} from "../index";
import {CONTAINER_OPTIONS, CODECS_OPTIONS, RESOLUTION_OPTIONS, SOURCES_OPTIONS} from "../domain/constants";
import {Field, Form} from "react-final-form";
import {MultiSelectField, TextField} from "../components/inputs";
import DEBUG from "../components/debug";
import TitleSubtitle from "../components/headings/TitleSubtitle";
import { SwitchGroup } from "../components/inputs";
import {classNames} from "../styles/utils";
import { FilterAddForm, FilterActionAddForm} from "../forms";
import Select from "react-select";
import APIClient from "../api/APIClient";
const tabs = [
{name: 'General', href: '', current: true},
// { name: 'TV', href: 'tv', current: false },
// { name: 'Movies', href: 'movies', current: false },
{name: 'Movies and TV', href: 'movies-tv', current: false},
// { name: 'P2P', href: 'p2p', current: false },
{name: 'Advanced', href: 'advanced', current: false},
{name: 'Actions', href: 'actions', current: false},
]
function TabNavLink({item, url}: any) {
const location = useLocation();
const {pathname} = location;
const splitLocation = pathname.split("/");
// we need to clean the / if it's a base root path
let too = item.href ? `${url}/${item.href}` : url
return (
<NavLink
key={item.name}
to={too}
exact={true}
activeClassName="border-purple-500 text-purple-600"
className={classNames(
'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm'
)}
aria-current={splitLocation[2] === item.href ? 'page' : undefined}
>
{item.name}
</NavLink>
)
}
export function Filters() {
const [createFilterIsOpen, toggleCreateFilter] = useToggle(false)
const {isLoading, error, data} = useQuery<Filter[], Error>('filter', APIClient.filters.getAll,
{
refetchOnWindowFocus: false
}
);
if (isLoading) {
return null
}
if (error) return (<p>'An error has occurred: '</p>)
return (
<main className="-mt-48 ">
<FilterAddForm isOpen={createFilterIsOpen} toggle={toggleCreateFilter}/>
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between">
<h1 className="text-3xl font-bold text-white capitalize">Filters</h1>
<div className="flex-shrink-0">
<button
type="button"
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={toggleCreateFilter}
>
Add new
</button>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow">
<div className="relative inset-0 py-3 px-3 sm:px-3 lg:px-3 h-full">
{data && data.length > 0 ? <FilterList filters={data}/> :
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter}/>}
</div>
</div>
</div>
</main>
)
}
interface FilterListProps {
filters: Filter[];
}
function FilterList({filters}: FilterListProps) {
return (
<div className="flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="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 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Indexers
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filters.map((filter: Filter, idx) => (
<FilterListItem filter={filter} key={idx} idx={idx}/>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
interface FilterListItemProps {
filter: Filter;
idx: number;
}
function FilterListItem({filter, idx}: FilterListItemProps) {
const [enabled, setEnabled] = useState(filter.enabled)
const toggleActive = (status: boolean) => {
console.log(status)
setEnabled(status)
// call api
}
return (
<tr key={filter.name}
className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<Switch
checked={enabled}
onChange={toggleActive}
className={classNames(
enabled ? 'bg-teal-500' : 'bg-gray-200',
'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-light-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
enabled ? '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>
</td>
<td className="px-6 py-4 w-full whitespace-nowrap text-sm font-medium text-gray-900">{filter.name}</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 text-gray-800">{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 hover:text-indigo-900">
Edit
</Link>
</td>
</tr>
)
}
const FormButtonsGroup = ({deleteAction}: any) => {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
const cancelButtonRef = useRef(null)
return (
<div className="pt-6 divide-y divide-gray-200">
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed z-10 inset-0 overflow-y-auto"
initialFocus={cancelButtonRef}
open={deleteModalIsOpen}
onClose={toggleDeleteModal}
>
<div
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true"/>
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3"
className="text-lg leading-6 font-medium text-gray-900">
Remove filter
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Are you sure you want to remove this filter?
This action cannot be undone.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={deleteAction}
>
Remove
</button>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
onClick={toggleDeleteModal}
ref={cancelButtonRef}
>
Cancel
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div>
<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-light-blue-500"
>
Cancel
</button>
<button
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save
</button>
</div>
</div>
</div>
)
}
export function FilterDetails() {
let {url} = useRouteMatch();
let history = useHistory();
let {filterId}: any = useParams();
const {isLoading, data} = useQuery<Filter, Error>(['filter', parseInt(filterId)], () => APIClient.filters.getByID(parseInt(filterId)),
{
retry: false,
refetchOnWindowFocus: false,
onError: err => {
history.push("./")
}
},
)
if (isLoading) {
return null
}
if (!data) {
return (<p>Something went wrong</p>)
}
return (
<main className="-mt-48 ">
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center">
<h1 className="text-3xl font-bold text-white capitalize">
<NavLink to="/filters" exact={true}>
Filters
</NavLink>
</h1>
<ChevronRightIcon className="h-6 w-6 text-gray-500" aria-hidden="true"/>
<h1 className="text-3xl font-bold text-white capitalize">{data.name}</h1>
</div>
</header>
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow">
<div className="relative mx-auto md:px-6 xl:px-4">
<div className="px-4 sm:px-6 md:px-0">
<div className="py-6">
{/* Tabs */}
<div className="lg:hidden">
<label htmlFor="selected-tab" className="sr-only">
Select a tab
</label>
<select
id="selected-tab"
name="selected-tab"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-purple-500 focus:border-purple-500 sm:text-sm rounded-md"
>
{tabs.map((tab) => (
<option key={tab.name}>{tab.name}</option>
))}
</select>
</div>
<div className="hidden lg:block">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<TabNavLink item={tab} url={url} key={tab.href}/>
))}
</nav>
</div>
</div>
<RouteSwitch>
<Route exact path={url}>
<FilterTabGeneral filter={data}/>
</Route>
<Route path={`${url}/movies-tv`}>
{/*<FilterTabMoviesTv filter={data}/>*/}
<FilterTabMoviesTvNew2 filter={data}/>
</Route>
{/*<Route path={`${path}/movies`}>*/}
{/* <p>movies</p>*/}
{/*</Route>*/}
{/*<Route path={`${path}/p2p`}>*/}
{/* <p>p2p</p>*/}
{/*</Route>*/}
<Route path={`${url}/advanced`}>
<FilterTabAdvanced filter={data}/>
</Route>
<Route path={`${url}/actions`}>
<FilterTabActions filter={data}/>
</Route>
</RouteSwitch>
</div>
</div>
</div>
</div>
</div>
</main>
)
}
interface FilterTabGeneralProps {
filter: Filter;
}
function FilterTabGeneral({filter}: FilterTabGeneralProps) {
const history = useHistory();
const { data } = useQuery<Indexer[], Error>('indexerList', APIClient.indexers.getOptions,
{
refetchOnWindowFocus: false
}
)
const updateMutation = useMutation((filter: Filter) => APIClient.filters.update(filter), {
onSuccess: () => {
// queryClient.setQueryData(['filter', filter.id], data)
queryClient.invalidateQueries(["filter",filter.id]);
}
})
const deleteMutation = useMutation((id: number) => APIClient.filters.delete(id), {
onSuccess: () => {
// invalidate filters
queryClient.invalidateQueries("filter");
// redirect
history.push("/filters")
}
})
const submitOther = (data: Filter) => {
updateMutation.mutate(data)
}
const deleteAction = () => {
deleteMutation.mutate(filter.id)
}
return (
<div>
<Form
initialValues={{
id: filter.id,
name: filter.name,
enabled: filter.enabled,
min_size: filter.min_size,
max_size: filter.max_size,
delay: filter.delay,
shows: filter.shows,
years: filter.years,
resolutions: filter.resolutions || [],
sources: filter.sources || [],
codecs: filter.codecs || [],
containers: filter.containers || [],
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,
match_tags: filter.match_tags,
except_tags: filter.except_tags,
match_uploaders: filter.match_uploaders,
except_uploaders: filter.except_uploaders,
indexers: filter.indexers || [],
}}
// validate={validate}
onSubmit={submitOther}
>
{({handleSubmit, submitting, values, valid}) => {
return (
<form onSubmit={handleSubmit}>
<div>
<div className="mt-6 lg:pb-8">
<div className="mt-6 grid grid-cols-12 gap-6">
<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 uppercase tracking-wide">
Indexers
</label>
<Field
name="indexers"
multiple={true}
parse={val => val && val.map((item: any) => ({ id: item.value.id, name: item.value.name, enabled: item.value.enabled, identifier: item.value.identifier}))}
format={values => values.map((val: any) => ({ label: val.name, value: val}))}
render={({input, meta}) => (
<Select {...input}
isClearable={true}
isMulti={true}
placeholder="Choose indexers"
className="mt-2 block w-full focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
options={data ? data.map(v => ({
label: v.name,
value: v
})) : []}
/>
)}
/>
</div>
</div>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Rules" subtitle="Set rules"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="min_size" label="Min size" columns={6} placeholder=""/>
<TextField name="max_size" label="Max size" columns={6} placeholder=""/>
<TextField name="delay" label="Delay" columns={6} placeholder=""/>
</div>
</div>
<div className="">
<SwitchGroup name="enabled" label="Enabled" description="Enabled or disable filter."/>
</div>
</div>
<FormButtonsGroup deleteAction={deleteAction}/>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
);
}
function FilterTabMoviesTvNew2({filter}: FilterTabGeneralProps) {
const history = useHistory();
const updateMutation = useMutation((filter: Filter) => APIClient.filters.update(filter), {
onSuccess: () => {
// queryClient.setQueryData(['filter', filter.id], data)
queryClient.invalidateQueries(["filter",filter.id]);
}
})
const deleteMutation = useMutation((id: number) => APIClient.filters.delete(id), {
onSuccess: () => {
// invalidate filters
queryClient.invalidateQueries("filter");
// redirect
history.push("/filters")
}
})
const deleteAction = () => {
deleteMutation.mutate(filter.id)
}
const submitOther = (data: Filter) => {
updateMutation.mutate(data)
}
return (
<div>
<Form
initialValues={{
id: filter.id,
name: filter.name,
enabled: filter.enabled,
min_size: filter.min_size,
max_size: filter.max_size,
delay: filter.delay,
shows: filter.shows,
years: filter.years,
resolutions: filter.resolutions || [],
sources: filter.sources || [],
codecs: filter.codecs || [],
containers: filter.containers || [],
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,
match_tags: filter.match_tags,
except_tags: filter.except_tags,
match_uploaders: filter.match_uploaders,
except_uploaders: filter.except_uploaders,
indexers: filter.indexers || [],
}}
// validate={validate}
onSubmit={submitOther}
>
{({handleSubmit, submitting, values, valid}) => {
return (
<form onSubmit={handleSubmit}>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="shows" label="Movies / Shows" columns={8} placeholder="eg. Movie, Show 1, Show?2"/>
<TextField name="years" label="Years" columns={4} placeholder="eg. 2018,2019-2021"/>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Seasons and Episodes" subtitle="Set seaons and episodes"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1, 3, 2-6"/>
<TextField name="episodes" label="Episodes" columns={4} placeholder="eg. 2, 4, 10-20"/>
</div>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Quality" subtitle="Resolution, source etc."/>
<div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelectField name="resolutions" options={RESOLUTION_OPTIONS} label="resolutions" columns={6}/>
<MultiSelectField name="sources" options={SOURCES_OPTIONS} label="sources" columns={6}/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<MultiSelectField name="codecs" options={CODECS_OPTIONS} label="codecs" columns={6}/>
<MultiSelectField name="containers" options={CONTAINER_OPTIONS} label="containers" columns={6}/>
</div>
</div>
<FormButtonsGroup deleteAction={deleteAction}/>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
)
}
function FilterTabAdvanced({filter}: FilterTabGeneralProps) {
const history = useHistory();
const updateMutation = useMutation((filter: Filter) => APIClient.filters.update(filter), {
onSuccess: () => {
// queryClient.setQueryData(['filter', filter.id], data)
queryClient.invalidateQueries(["filter",filter.id]);
}
})
const deleteMutation = useMutation((id: number) => APIClient.filters.delete(id), {
onSuccess: () => {
// invalidate filters
queryClient.invalidateQueries("filter");
// redirect
history.push("/filters")
}
})
const deleteAction = () => {
deleteMutation.mutate(filter.id)
}
const submitOther = (data: Filter) => {
updateMutation.mutate(data)
}
return (
<div>
<Form
initialValues={{
id: filter.id,
name: filter.name,
enabled: filter.enabled,
min_size: filter.min_size,
max_size: filter.max_size,
delay: filter.delay,
shows: filter.shows,
years: filter.years,
resolutions: filter.resolutions || [],
sources: filter.sources || [],
codecs: filter.codecs || [],
containers: filter.containers || [],
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,
match_tags: filter.match_tags,
except_tags: filter.except_tags,
match_uploaders: filter.match_uploaders,
except_uploaders: filter.except_uploaders,
indexers: filter.indexers || [],
}}
// validate={validate}
onSubmit={submitOther}
>
{({handleSubmit, submitting, values, valid}) => {
return (
<form onSubmit={handleSubmit}>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Releases" subtitle="Releases"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="match_releases" label="Match releases" columns={6} placeholder=""/>
<TextField name="except_releases" label="Except releases" columns={6}
placeholder=""/>
</div>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Release groups"
subtitle="Match or ignore certain release groups"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="match_release_groups" label="Match groups" columns={6}
placeholder=""/>
<TextField name="except_release_groups" label="Except groups" columns={6}
placeholder=""/>
</div>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Categories" subtitle="Match or ignore certain categories"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="match_categories" label="Match categories" columns={6}
placeholder=""/>
<TextField name="except_categories" label="Except categories" columns={6}
placeholder=""/>
</div>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Tags" subtitle="Match or ignore certain tags"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="match_tags" label="Match tags" columns={6} placeholder=""/>
<TextField name="except_tags" label="Except tags" columns={6} placeholder=""/>
</div>
</div>
<div className="mt-6 lg:pb-8">
<TitleSubtitle title="Uploaders" subtitle="Match or ignore certain uploaders"/>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField name="match_uploaders" label="Match uploaders" columns={6}
placeholder=""/>
<TextField name="except_uploaders" label="Except uploaders" columns={6}
placeholder=""/>
</div>
</div>
<FormButtonsGroup deleteAction={deleteAction}/>
<DEBUG values={values}/>
</form>
)
}}
</Form>
</div>
)
}
function FilterTabActions({filter}: FilterTabGeneralProps) {
const [addActionIsOpen, toggleAddAction] = useToggle(false)
const {data} = useQuery<DownloadClient[], Error>('downloadClients', APIClient.download_clients.getAll,
{
refetchOnWindowFocus: false
}
)
return (
<div className="mt-10">
{addActionIsOpen &&
<FilterActionAddForm filter={filter} clients={data || []} isOpen={addActionIsOpen} toggle={toggleAddAction}/>
}
<div>
<div className="-ml-4 -mt-4 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">Actions</h3>
<p className="mt-1 text-sm text-gray-500">
Actions
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={toggleAddAction}
>
Add new
</button>
</div>
</div>
{filter.actions ? <FilterActionList actions={filter.actions} clients={data || []} filterID={filter.id}/> :
<EmptyListState text="No actions yet!"/>}
</div>
</div>
)
}

View file

@ -0,0 +1,144 @@
import React from 'react'
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
import {
BrowserRouter as Router,
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 "../styles/utils";
import ActionSettings from "./settings/Action";
import {useRecoilValue} from "recoil";
import {configState} from "../state/state";
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: 'Actions', href: 'actions', icon: PlayIcon, current: false},
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
// {name: 'Notifications', href: 'notifications', icon: BellIcon, current: false},
]
function SubNavLink({item, url}: any) {
const location = useLocation();
const {pathname} = location;
const splitLocation = pathname.split("/");
// we need to clean the / if it's a base root path
let too = item.href ? `${url}/${item.href}` : url
return (
<NavLink
key={item.name}
to={too}
exact={true}
activeClassName="bg-teal-50 border-teal-500 text-teal-700 hover:bg-teal-50 hover:text-teal-700"
className={classNames(
'border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium'
)}
aria-current={splitLocation[2] === item.href ? 'page' : undefined}
>
<item.icon
className={classNames(
splitLocation[2] === item.href
? 'text-teal-500 group-hover:text-teal-500'
: 'text-gray-400 group-hover:text-gray-500',
'flex-shrink-0 -ml-1 mr-3 h-6 w-6'
)}
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</NavLink>
)
}
function SidebarNav({subNavigation, url}: any) {
return (
<aside className="py-6 lg:col-span-3">
<nav className="space-y-1">
{subNavigation.map((item: any) => (
<SubNavLink item={item} url={url} key={item.href}/>
))}
</nav>
</aside>
)
}
export function buildPath(...args: string[]): string {
const [first] = args;
const firstTrimmed = first.trim();
const result = args
.map((part) => part.trim())
.map((part, i) => {
if (i === 0) {
return part.replace(/[/]*$/g, '');
} else {
return part.replace(/(^[/]*|[/]*$)/g, '');
}
})
.filter((x) => x.length)
.join('/');
return firstTrimmed === '/' ? `/${result}` : result;
}
export default function Settings() {
const config = useRecoilValue(configState)
let { url } = useRouteMatch();
let p = config.base_url ? buildPath(config.base_url, url) : url
return (
<Router>
<main className="relative -mt-48">
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white capitalize">Settings</h1>
</div>
</header>
<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 rounded-lg shadow overflow-hidden">
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<SidebarNav url={p} subNavigation={subNavigation}/>
<RouteSwitch>
<Route exact path={p}>
<ApplicationSettings />
</Route>
<Route path={`${p}/indexers`}>
<IndexerSettings />
</Route>
<Route path={`${p}/irc`}>
<IrcSettings />
</Route>
<Route path={`${p}/clients`}>
<DownloadClientSettings />
</Route>
<Route path={`${p}/actions`}>
<ActionSettings />
</Route>
</RouteSwitch>
</div>
</div>
</div>
</main>
</Router>
)
}

View file

@ -0,0 +1,101 @@
import React from "react";
function ActionSettings() {
// const [addClientIsOpen, toggleAddClient] = useToggle(false)
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
{/*{addClientIsOpen &&*/}
{/*<AddNewClientForm isOpen={addClientIsOpen} toggle={toggleAddClient}/>*/}
{/*}*/}
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">Actions</h3>
<p className="mt-1 text-sm text-gray-500">
Manage actions.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
// onClick={toggleAddClient}
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="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 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Port
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
<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

@ -0,0 +1,119 @@
import React, {useState} from "react";
import {Switch} from "@headlessui/react";
import { classNames } from "../../styles/utils";
import {useRecoilState} from "recoil";
import {configState} from "../../state/state";
function ApplicationSettings() {
const [isDebug, setIsDebug] = useState(true)
const [config] = useRecoilState(configState)
return (
<form className="divide-y divide-gray-200 lg:col-span-9" action="#" method="POST">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Application</h2>
<p className="mt-1 text-sm text-gray-500">
Application settings. Change in config.toml and restart to take effect.
</p>
</div>
<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-sm font-medium text-gray-700">
Host
</label>
<input
type="text"
name="host"
id="host"
value={config.host}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
<div className="col-span-6 sm:col-span-4">
<label htmlFor="port" className="block text-sm font-medium text-gray-700">
Port
</label>
<input
type="text"
name="port"
id="port"
value={config.port}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
<div className="col-span-6 sm:col-span-4">
<label htmlFor="base_url" className="block text-sm font-medium text-gray-700">
Base url
</label>
<input
type="text"
name="base_url"
id="base_url"
value={config.base_url}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
<div className="pt-6 pb-6 divide-y divide-gray-200">
<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"
passive>
Debug
</Switch.Label>
<Switch.Description className="text-sm text-gray-500">
Enable debug mode to get more logs.
</Switch.Description>
</div>
<Switch
checked={isDebug}
onChange={setIsDebug}
className={classNames(
isDebug ? 'bg-teal-500' : 'bg-gray-200',
'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-light-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>*/}
</div>
</form>
)
}
export default ApplicationSettings;

View file

@ -0,0 +1,148 @@
import {useState} from "react";
import {DownloadClient} from "../../domain/interfaces";
import {useToggle} from "../../hooks/hooks";
import {Switch} from "@headlessui/react";
import {useQuery} from "react-query";
import {classNames} from "../../styles/utils";
import { DownloadClientAddForm, DownloadClientUpdateForm } from "../../forms";
import EmptySimple from "../../components/empty/EmptySimple";
import APIClient from "../../api/APIClient";
interface DownloadLClientSettingsListItemProps {
client: DownloadClient;
idx: number;
}
function DownloadClientSettingsListItem({ client, idx }: DownloadLClientSettingsListItemProps) {
const [enabled, setEnabled] = useState(client.enabled)
const [updateClientIsOpen, toggleUpdateClient] = useToggle(false)
const toggleActive = (status: boolean) => {
console.log(status)
setEnabled(status)
// call api
}
return (
<tr key={client.name} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
{updateClientIsOpen &&
<DownloadClientUpdateForm client={client} isOpen={updateClientIsOpen} toggle={toggleUpdateClient}/>
}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<Switch
checked={client.enabled}
onChange={toggleActive}
className={classNames(
client.enabled ? 'bg-teal-500' : 'bg-gray-200',
'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-light-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
client.enabled ? '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>
</td>
<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-right text-sm font-medium">
<span className="text-indigo-600 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdateClient}>
Edit
</span>
</td>
</tr>
)
}
function DownloadClientSettings() {
const [addClientIsOpen, toggleAddClient] = useToggle(false)
const { error, data } = useQuery<DownloadClient[], Error>('downloadClients', APIClient.download_clients.getAll,
{
refetchOnWindowFocus: false
})
if (error) return (<p>'An error has occurred: '</p>);
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
{addClientIsOpen &&
<DownloadClientAddForm isOpen={addClientIsOpen} toggle={toggleAddClient}/>
}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">Clients</h3>
<p className="mt-1 text-sm text-gray-500">
Manage download clients.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={toggleAddClient}
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
{data && data.length > 0 ?
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="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 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
{data && data.map((client, idx) => (
<DownloadClientSettingsListItem client={client} idx={idx} key={idx} />
))}
</tbody>
</table>
</div>
</div>
</div>
: <EmptySimple title="No download clients" subtitle="Add a new client" buttonText="New client" buttonAction={toggleAddClient} />
}
</div>
</div>
</div>
)
}
export default DownloadClientSettings;

View file

@ -0,0 +1,127 @@
import {useToggle} from "../../hooks/hooks";
import {useQuery} from "react-query";
import React, {useEffect} from "react";
import {IndexerAddForm, IndexerUpdateForm} from "../../forms";
import {Indexer} from "../../domain/interfaces";
import {Switch} from "@headlessui/react";
import {classNames} from "../../styles/utils";
import EmptySimple from "../../components/empty/EmptySimple";
import APIClient from "../../api/APIClient";
const ListItem = ({ indexer }: any) => {
const [updateIsOpen, toggleUpdate] = useToggle(false)
return (
<tr key={indexer.name}>
{updateIsOpen && <IndexerUpdateForm isOpen={updateIsOpen} toggle={toggleUpdate} indexer={indexer} />}
<td className="px-6 py-4 whitespace-nowrap">
<Switch
checked={indexer.enabled}
onChange={toggleUpdate}
className={classNames(
indexer.enabled ? 'bg-teal-500' : 'bg-gray-200',
'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-light-blue-500'
)}
>
<span className="sr-only">Enable</span>
<span
aria-hidden="true"
className={classNames(
indexer.enabled ? '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>
</td>
<td className="px-6 py-4 w-full whitespace-nowrap text-sm font-medium text-gray-900">{indexer.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<span className="text-indigo-600 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
Edit
</span>
</td>
</tr>
)
}
function IndexerSettings() {
const [addIndexerIsOpen, toggleAddIndexer] = useToggle(false)
const {error, data} = useQuery<any[], Error>('indexer', APIClient.indexers.getAll,
{
refetchOnWindowFocus: false
}
)
useEffect(() => {
}, []);
if (error) return (<p>An error has occurred</p>)
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<IndexerAddForm isOpen={addIndexerIsOpen} toggle={toggleAddIndexer} />
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">Indexers</h3>
<p className="mt-1 text-sm text-gray-500">
Indexer settings.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
onClick={toggleAddIndexer}
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
{data && data.length > 0 ?
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="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 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data && data.map((indexer: Indexer, idx: number) => (
<ListItem indexer={indexer} key={idx}/>
))}
</tbody>
</table>
</div>
</div>
</div>
: <EmptySimple title="No indexers" subtitle="Add a new indexer" buttonText="New indexer" buttonAction={toggleAddIndexer}/>
}
</div>
</div>
</div>
)
}
export default IndexerSettings;

View file

@ -0,0 +1,153 @@
import React, {useEffect} from "react";
import {IrcNetworkAddForm} from "../../forms";
import {useToggle} from "../../hooks/hooks";
import {useQuery} from "react-query";
import IrcNetworkUpdateForm from "../../forms/settings/IrcNetworkUpdateForm";
import {Switch} from "@headlessui/react";
import {classNames} from "../../styles/utils";
import EmptySimple from "../../components/empty/EmptySimple";
import APIClient from "../../api/APIClient";
interface IrcNetwork {
id: number;
name: string;
enabled: boolean;
addr: string;
nick: string;
username: string;
realname: string;
pass: string;
// connect_commands: string;
}
function IrcSettings() {
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false)
useEffect(() => {
}, []);
const { data } = useQuery<any[], Error>('networks', APIClient.irc.getNetworks,
{
refetchOnWindowFocus: false
}
)
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
{addNetworkIsOpen &&
<IrcNetworkAddForm isOpen={addNetworkIsOpen} toggle={toggleAddNetwork}/>
}
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">IRC</h3>
<p className="mt-1 text-sm text-gray-500">
IRC networks and channels.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
onClick={toggleAddNetwork}
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 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
{data && data.length > 0 ?
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="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 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Network
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Addr
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Nick
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
{data && data.map((network: IrcNetwork, idx) => (
<ListItem key={idx} idx={idx} network={network}/>
))}
</tbody>
</table>
</div>
</div>
</div>
: <EmptySimple title="No networks" subtitle="Add a new network" buttonText="New network" buttonAction={toggleAddNetwork}/>
}
</div>
</div>
</div>
)
}
const ListItem = ({ idx, network }: any) => {
const [updateIsOpen, toggleUpdate] = useToggle(false)
return (
<tr key={network.name} className={idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
{updateIsOpen && <IrcNetworkUpdateForm isOpen={updateIsOpen} toggle={toggleUpdate} network={network} />}
<td className="px-6 py-4 whitespace-nowrap">
<Switch
checked={network.enabled}
onChange={toggleUpdate}
className={classNames(
network.enabled ? 'bg-teal-500' : 'bg-gray-200',
'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-light-blue-500'
)}
>
<span className="sr-only">Enable</span>
<span
aria-hidden="true"
className={classNames(
network.enabled ? '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>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{network.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{network.addr} {network.tls && <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">TLS</span>}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{network.nick}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<span className="text-indigo-600 hover:text-indigo-900 cursor-pointer" onClick={toggleUpdate}>
Edit
</span>
</td>
</tr>
)
}
export default IrcSettings;

5
web/src/setupTests.ts Normal file
View file

@ -0,0 +1,5 @@
// 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';

12
web/src/state/state.ts Normal file
View file

@ -0,0 +1,12 @@
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",
}
});

7
web/src/styles/utils.ts Normal file
View file

@ -0,0 +1,7 @@
// concatenate classes
export function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ')
}
// column widths for inputs etc
export type COL_WIDTHS = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;

4
web/src/utils/utils.ts Normal file
View file

@ -0,0 +1,4 @@
// sleep for x ms
export function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

38
web/tailwind.config.js Normal file
View file

@ -0,0 +1,38 @@
const colors = require('tailwindcss/colors')
module.exports = {
purge: {
content: [
'./src/**/*.{tsx,ts,html,css}',
],
safelist: [
'col-span-1',
'col-span-2',
'col-span-3',
'col-span-4',
'col-span-5',
'col-span-6',
'col-span-7',
'col-span-8',
'col-span-9',
'col-span-10',
'col-span-11',
'col-span-12',
],
},
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
gray: colors.gray,
teal: colors.teal,
}
},
},
variants: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
}

26
web/tsconfig.json Normal file
View file

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

12054
web/yarn.lock Normal file

File diff suppressed because it is too large Load diff