mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00
feat: add webui
This commit is contained in:
parent
a838d994a6
commit
773e57afe6
59 changed files with 19794 additions and 0 deletions
46
web/README.md
Normal file
46
web/README.md
Normal 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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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
61
web/build.go
Normal 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
10
web/craco.config.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
style: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
64
web/package.json
Normal file
64
web/package.json
Normal 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
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
33
web/public/index.html
Normal 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
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
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
25
web/public/manifest.json
Normal 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
3
web/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
110
web/src/api/APIClient.ts
Normal file
110
web/src/api/APIClient.ts
Normal 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;
|
24
web/src/components/EmptyListState.tsx
Normal file
24
web/src/components/EmptyListState.tsx
Normal 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>
|
||||
)
|
||||
}
|
665
web/src/components/FilterActionList.tsx
Normal file
665
web/src/components/FilterActionList.tsx
Normal 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">
|
||||
​
|
||||
</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>
|
||||
)
|
||||
}
|
15
web/src/components/debug.tsx
Normal file
15
web/src/components/debug.tsx
Normal 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;
|
27
web/src/components/empty/EmptySimple.tsx
Normal file
27
web/src/components/empty/EmptySimple.tsx
Normal 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;
|
15
web/src/components/headings/TitleSubtitle.tsx
Normal file
15
web/src/components/headings/TitleSubtitle.tsx
Normal 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;
|
20
web/src/components/inputs/Error.tsx
Normal file
20
web/src/components/inputs/Error.tsx
Normal 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;
|
50
web/src/components/inputs/MultiSelectField.tsx
Normal file
50
web/src/components/inputs/MultiSelectField.tsx
Normal 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;
|
60
web/src/components/inputs/RadioFieldset.tsx
Normal file
60
web/src/components/inputs/RadioFieldset.tsx
Normal 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;
|
55
web/src/components/inputs/SwitchGroup.tsx
Normal file
55
web/src/components/inputs/SwitchGroup.tsx
Normal 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;
|
40
web/src/components/inputs/TextAreaWide.tsx
Normal file
40
web/src/components/inputs/TextAreaWide.tsx
Normal 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;
|
45
web/src/components/inputs/TextField.tsx
Normal file
45
web/src/components/inputs/TextField.tsx
Normal 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;
|
41
web/src/components/inputs/TextFieldWide.tsx
Normal file
41
web/src/components/inputs/TextFieldWide.tsx
Normal 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;
|
6
web/src/components/inputs/index.ts
Normal file
6
web/src/components/inputs/index.ts
Normal 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";
|
92
web/src/components/modals/Delete.tsx
Normal file
92
web/src/components/modals/Delete.tsx
Normal 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">
|
||||
​
|
||||
</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;
|
1
web/src/components/modals/index.ts
Normal file
1
web/src/components/modals/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as DeleteModal } from "./Delete";
|
78
web/src/domain/constants.ts
Normal file
78
web/src/domain/constants.ts
Normal 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},
|
||||
];
|
119
web/src/domain/interfaces.ts
Normal file
119
web/src/domain/interfaces.ts
Normal 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;
|
||||
}
|
829
web/src/forms/filters/FilterActionAddForm.tsx
Normal file
829
web/src/forms/filters/FilterActionAddForm.tsx
Normal 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;
|
834
web/src/forms/filters/FilterActionUpdateForm.tsx
Normal file
834
web/src/forms/filters/FilterActionUpdateForm.tsx
Normal 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;
|
151
web/src/forms/filters/FilterAddForm.tsx
Normal file
151
web/src/forms/filters/FilterAddForm.tsx
Normal 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
11
web/src/forms/index.ts
Normal 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";
|
412
web/src/forms/settings/DownloadClientAddForm.tsx
Normal file
412
web/src/forms/settings/DownloadClientAddForm.tsx
Normal 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;
|
506
web/src/forms/settings/DownloadClientUpdateForm.tsx
Normal file
506
web/src/forms/settings/DownloadClientUpdateForm.tsx
Normal 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">
|
||||
​
|
||||
</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;
|
241
web/src/forms/settings/IndexerAddForm.tsx
Normal file
241
web/src/forms/settings/IndexerAddForm.tsx
Normal 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;
|
307
web/src/forms/settings/IndexerUpdateForm.tsx
Normal file
307
web/src/forms/settings/IndexerUpdateForm.tsx
Normal 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">
|
||||
​
|
||||
</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;
|
321
web/src/forms/settings/IrcNetworkAddForm.tsx
Normal file
321
web/src/forms/settings/IrcNetworkAddForm.tsx
Normal 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;
|
383
web/src/forms/settings/IrcNetworkUpdateForm.tsx
Normal file
383
web/src/forms/settings/IrcNetworkUpdateForm.tsx
Normal 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
9
web/src/hooks/hooks.ts
Normal 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
17
web/src/index.css
Normal 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
61
web/src/index.tsx
Normal 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
1
web/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
15
web/src/reportWebVitals.ts
Normal file
15
web/src/reportWebVitals.ts
Normal 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
202
web/src/screens/Base.tsx
Normal 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>
|
||||
)
|
||||
}
|
18
web/src/screens/Dashboard.tsx
Normal file
18
web/src/screens/Dashboard.tsx
Normal 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
833
web/src/screens/Filters.tsx
Normal 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">
|
||||
​
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
144
web/src/screens/Settings.tsx
Normal file
144
web/src/screens/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
101
web/src/screens/settings/Action.tsx
Normal file
101
web/src/screens/settings/Action.tsx
Normal 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;
|
119
web/src/screens/settings/Application.tsx
Normal file
119
web/src/screens/settings/Application.tsx
Normal 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;
|
148
web/src/screens/settings/DownloadClient.tsx
Normal file
148
web/src/screens/settings/DownloadClient.tsx
Normal 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;
|
127
web/src/screens/settings/Indexer.tsx
Normal file
127
web/src/screens/settings/Indexer.tsx
Normal 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;
|
153
web/src/screens/settings/Irc.tsx
Normal file
153
web/src/screens/settings/Irc.tsx
Normal 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
5
web/src/setupTests.ts
Normal 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
12
web/src/state/state.ts
Normal 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
7
web/src/styles/utils.ts
Normal 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
4
web/src/utils/utils.ts
Normal 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
38
web/tailwind.config.js
Normal 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
26
web/tsconfig.json
Normal 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
12054
web/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue