feat: add webui

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,55 @@
import React from "react";
import {Switch} from "@headlessui/react";
import {Field} from "react-final-form";
import {classNames} from "../../styles/utils";
interface Props {
name: string;
label: string;
description?: string;
className?: string;
}
const SwitchGroup: React.FC<Props> = ({name, label, description}) => (
<ul className="mt-2 divide-y divide-gray-200">
<Switch.Group as="li" className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900"
passive>
{label}
</Switch.Label>
{description && (
<Switch.Description className="text-sm text-gray-500">
{description}
</Switch.Description>
)}
</div>
<Field
name={name}
render={({input: {onChange, checked, value}}) => (
<Switch
value={value}
checked={value}
onChange={onChange}
className={classNames(
value ? 'bg-teal-500' : 'bg-gray-200',
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-light-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
value ? 'translate-x-5' : 'translate-x-0',
'inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200'
)}
/>
</Switch>
)}
/>
</Switch.Group>
</ul>
)
export default SwitchGroup;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,119 @@
export interface APP {
baseUrl: string;
}
export interface Action {
id: number;
name: string;
enabled: boolean;
type: ActionType;
exec_cmd: string;
exec_args: string;
watch_folder: string;
category: string;
tags: string;
label: string;
save_path: string;
paused: boolean;
ignore_rules: boolean;
limit_upload_speed: number;
limit_download_speed: number;
client_id: number;
filter_id: number;
// settings: object;
}
export interface Indexer {
id: number;
name: string;
identifier: string;
enabled: boolean;
settings: object | any;
}
export interface Filter {
id: number;
name: string;
enabled: boolean;
shows: string;
min_size: string;
max_size: string;
match_sites: string[];
except_sites: string[];
delay: number;
years: string;
resolutions: string[];
sources: string[];
codecs: string[];
containers: string[];
seasons: string;
episodes: string;
match_releases: string;
except_releases: string;
match_release_groups: string;
except_release_groups: string;
match_categories: string;
except_categories: string;
match_tags: string;
except_tags: string;
match_uploaders: string;
except_uploaders: string;
actions: Action[];
indexers: Indexer[];
}
export interface Tracker {
id: number;
name: string;
type: string;
enabled: boolean;
}
export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE';
export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE'];
export type DownloadClientType = 'QBITTORRENT' | 'DELUGE';
// export const DOWNLOAD_CLIENT_TYPES: DownloadClientType[] = ['QBITTORRENT' , 'DELUGE'];
export enum DOWNLOAD_CLIENT_TYPES {
qBittorrent = 'QBITTORRENT',
Deluge = 'DELUGE'
}
export interface DownloadClient {
id: number;
name: string;
enabled: boolean;
type: DownloadClientType;
settings: object;
}
export interface Network {
id: number;
name: string;
enabled: boolean;
addr: string;
nick: string;
username: string;
realname: string;
pass: string;
sasl: SASL;
}
export interface SASL {
mechanism: string;
plain: {
username: string;
password: string;
}
}
export interface Config {
host: string;
port: number;
log_level: string;
log_path: string;
base_url: string;
}

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

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

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

View file

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

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

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

View file

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

View file

@ -0,0 +1,101 @@
import React from "react";
function ActionSettings() {
// const [addClientIsOpen, toggleAddClient] = useToggle(false)
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
{/*{addClientIsOpen &&*/}
{/*<AddNewClientForm isOpen={addClientIsOpen} toggle={toggleAddClient}/>*/}
{/*}*/}
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">Actions</h3>
<p className="mt-1 text-sm text-gray-500">
Manage actions.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
// onClick={toggleAddClient}
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Port
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>empty</td>
</tr>
{/*{downloadclients.map((client, personIdx) => (*/}
{/* <tr key={client.name}*/}
{/* className={personIdx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{client.name}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{client.type}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{client.port}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{client.enabled}</td>*/}
{/* <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">*/}
{/* <Link to="edit" className="text-indigo-600 hover:text-indigo-900">*/}
{/* Edit*/}
{/* </Link>*/}
{/* </td>*/}
{/* </tr>*/}
{/*))}*/}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default ActionSettings;

View file

@ -0,0 +1,119 @@
import React, {useState} from "react";
import {Switch} from "@headlessui/react";
import { classNames } from "../../styles/utils";
import {useRecoilState} from "recoil";
import {configState} from "../../state/state";
function ApplicationSettings() {
const [isDebug, setIsDebug] = useState(true)
const [config] = useRecoilState(configState)
return (
<form className="divide-y divide-gray-200 lg:col-span-9" action="#" method="POST">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Application</h2>
<p className="mt-1 text-sm text-gray-500">
Application settings. Change in config.toml and restart to take effect.
</p>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6 sm:col-span-4">
<label htmlFor="host" className="block text-sm font-medium text-gray-700">
Host
</label>
<input
type="text"
name="host"
id="host"
value={config.host}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
<div className="col-span-6 sm:col-span-4">
<label htmlFor="port" className="block text-sm font-medium text-gray-700">
Port
</label>
<input
type="text"
name="port"
id="port"
value={config.port}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
<div className="col-span-6 sm:col-span-4">
<label htmlFor="base_url" className="block text-sm font-medium text-gray-700">
Base url
</label>
<input
type="text"
name="base_url"
id="base_url"
value={config.base_url}
disabled={true}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
<div className="pt-6 pb-6 divide-y divide-gray-200">
<div className="px-4 sm:px-6">
<ul className="mt-2 divide-y divide-gray-200">
<Switch.Group as="li" className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900"
passive>
Debug
</Switch.Label>
<Switch.Description className="text-sm text-gray-500">
Enable debug mode to get more logs.
</Switch.Description>
</div>
<Switch
checked={isDebug}
onChange={setIsDebug}
className={classNames(
isDebug ? 'bg-teal-500' : 'bg-gray-200',
'ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-light-blue-500'
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
isDebug ? 'translate-x-5' : 'translate-x-0',
'inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200'
)}
/>
</Switch>
</Switch.Group>
</ul>
</div>
{/*<div className="mt-4 py-4 px-4 flex justify-end sm:px-6">*/}
{/* <button*/}
{/* type="button"*/}
{/* className="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"*/}
{/* >*/}
{/* Cancel*/}
{/* </button>*/}
{/* <button*/}
{/* type="submit"*/}
{/* className="ml-5 bg-indigo-700 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"*/}
{/* >*/}
{/* Save*/}
{/* </button>*/}
{/*</div>*/}
</div>
</form>
)
}
export default ApplicationSettings;

View file

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

View file

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

View file

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

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

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

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

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

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

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

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

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