mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
Feature: Radarr (#13)
* feat(web): add and update radarr * feat: add radarr download client * feat: add tests
This commit is contained in:
parent
0c4aaa29b0
commit
455284a94b
35 changed files with 2898 additions and 3348 deletions
65
internal/action/radarr.go
Normal file
65
internal/action/radarr.go
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
package action
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
"github.com/autobrr/autobrr/pkg/radarr"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *service) radarr(announce domain.Announce, action domain.Action) error {
|
||||||
|
log.Trace().Msg("action RADARR")
|
||||||
|
|
||||||
|
// TODO validate data
|
||||||
|
|
||||||
|
// get client for action
|
||||||
|
client, err := s.clientSvc.FindByID(action.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("error finding client: %v", action.ClientID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// return early if no client found
|
||||||
|
if client == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial config
|
||||||
|
cfg := radarr.Config{
|
||||||
|
Hostname: client.Host,
|
||||||
|
APIKey: client.Settings.APIKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// only set basic auth if enabled
|
||||||
|
if client.Settings.Basic.Auth {
|
||||||
|
cfg.BasicAuth = client.Settings.Basic.Auth
|
||||||
|
cfg.Username = client.Settings.Basic.Username
|
||||||
|
cfg.Password = client.Settings.Basic.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
r := radarr.New(cfg)
|
||||||
|
|
||||||
|
release := radarr.Release{
|
||||||
|
Title: announce.TorrentName,
|
||||||
|
DownloadUrl: announce.TorrentUrl,
|
||||||
|
Size: 0,
|
||||||
|
Indexer: announce.Site,
|
||||||
|
DownloadProtocol: "torrent",
|
||||||
|
Protocol: "torrent",
|
||||||
|
PublishDate: time.Now().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.Push(release)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr: failed to push release: %v", release)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO save pushed release
|
||||||
|
|
||||||
|
log.Debug().Msgf("radarr: successfully pushed release: %v, indexer %v to %v", release.Title, release.Indexer, client.Host)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -65,7 +65,14 @@ func (s *service) RunActions(torrentFile string, hash string, filter domain.Filt
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// pvr *arr
|
case domain.ActionTypeRadarr:
|
||||||
|
go func() {
|
||||||
|
err := s.radarr(announce, action)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("error sending torrent to radarr")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type)
|
log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ func NewDownloadClientRepo(db *sql.DB) domain.DownloadClientRepo {
|
||||||
|
|
||||||
func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) {
|
func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) {
|
||||||
|
|
||||||
rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password FROM client")
|
rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password, settings FROM client")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err)
|
log.Fatal().Err(err)
|
||||||
}
|
}
|
||||||
|
@ -29,14 +30,21 @@ func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) {
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f domain.DownloadClient
|
var f domain.DownloadClient
|
||||||
|
var settingsJsonStr string
|
||||||
|
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password, &settingsJsonStr); err != nil {
|
||||||
log.Error().Err(err)
|
log.Error().Err(err)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if settingsJsonStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(settingsJsonStr), &f.Settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clients = append(clients, f)
|
clients = append(clients, f)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
@ -49,7 +57,7 @@ func (r *DownloadClientRepo) List() ([]domain.DownloadClient, error) {
|
||||||
func (r *DownloadClientRepo) FindByID(id int32) (*domain.DownloadClient, error) {
|
func (r *DownloadClientRepo) FindByID(id int32) (*domain.DownloadClient, error) {
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, type, enabled, host, port, ssl, username, password FROM client WHERE id = ?
|
SELECT id, name, type, enabled, host, port, ssl, username, password, settings FROM client WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
row := r.db.QueryRow(query, id)
|
row := r.db.QueryRow(query, id)
|
||||||
|
@ -58,18 +66,25 @@ func (r *DownloadClientRepo) FindByID(id int32) (*domain.DownloadClient, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var client domain.DownloadClient
|
var client domain.DownloadClient
|
||||||
|
var settingsJsonStr string
|
||||||
|
|
||||||
if err := row.Scan(&client.ID, &client.Name, &client.Type, &client.Enabled, &client.Host, &client.Port, &client.SSL, &client.Username, &client.Password); err != nil {
|
if err := row.Scan(&client.ID, &client.Name, &client.Type, &client.Enabled, &client.Host, &client.Port, &client.SSL, &client.Username, &client.Password, &settingsJsonStr); err != nil {
|
||||||
log.Error().Err(err).Msg("could not scan download client to struct")
|
log.Error().Err(err).Msg("could not scan download client to struct")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if settingsJsonStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(settingsJsonStr), &client.Settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &client, nil
|
return &client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClient, error) {
|
func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClient, error) {
|
||||||
|
|
||||||
rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password FROM client, action_client WHERE client.id = action_client.client_id AND action_client.action_id = ?", actionID)
|
rows, err := r.db.Query("SELECT id, name, type, enabled, host, port, ssl, username, password, settings FROM client, action_client WHERE client.id = action_client.client_id AND action_client.action_id = ?", actionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err)
|
log.Fatal().Err(err)
|
||||||
}
|
}
|
||||||
|
@ -79,14 +94,21 @@ func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClie
|
||||||
var clients []domain.DownloadClient
|
var clients []domain.DownloadClient
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f domain.DownloadClient
|
var f domain.DownloadClient
|
||||||
|
var settingsJsonStr string
|
||||||
|
|
||||||
if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password); err != nil {
|
if err := rows.Scan(&f.ID, &f.Name, &f.Type, &f.Enabled, &f.Host, &f.Port, &f.SSL, &f.Username, &f.Password, &settingsJsonStr); err != nil {
|
||||||
log.Error().Err(err)
|
log.Error().Err(err)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if settingsJsonStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(settingsJsonStr), &f.Settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clients = append(clients, f)
|
clients = append(clients, f)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
@ -97,16 +119,27 @@ func (r *DownloadClientRepo) FindByActionID(actionID int) ([]domain.DownloadClie
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *DownloadClientRepo) Store(client domain.DownloadClient) (*domain.DownloadClient, error) {
|
func (r *DownloadClientRepo) Store(client domain.DownloadClient) (*domain.DownloadClient, error) {
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
settings := domain.DownloadClientSettings{
|
||||||
|
APIKey: client.Settings.APIKey,
|
||||||
|
Basic: client.Settings.Basic,
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsJson, err := json.Marshal(&settings)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("could not marshal download client settings %v", settings)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if client.ID != 0 {
|
if client.ID != 0 {
|
||||||
log.Info().Msg("UPDATE existing record")
|
log.Info().Msg("UPDATE existing record")
|
||||||
_, err = r.db.Exec(`UPDATE client SET name = ?, type = ?, enabled = ?, host = ?, port = ?, ssl = ?, username = ?, password = ? WHERE id = ?`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password, client.ID)
|
_, err = r.db.Exec(`UPDATE client SET name = ?, type = ?, enabled = ?, host = ?, port = ?, ssl = ?, username = ?, password = ?, settings = json_set(?) WHERE id = ?`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password, client.ID, settingsJson)
|
||||||
} else {
|
} else {
|
||||||
var res sql.Result
|
var res sql.Result
|
||||||
|
|
||||||
res, err = r.db.Exec(`INSERT INTO client(name, type, enabled, host, port, ssl, username, password)
|
res, err = r.db.Exec(`INSERT INTO client(name, type, enabled, host, port, ssl, username, password, settings)
|
||||||
VALUES (?, ?, ?, ?, ?, ? , ?, ?) ON CONFLICT DO NOTHING`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password)
|
VALUES (?, ?, ?, ?, ?, ? , ?, ?, json_set(?)) ON CONFLICT DO NOTHING`, client.Name, client.Type, client.Enabled, client.Host, client.Port, client.SSL, client.Username, client.Password, settingsJson)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err)
|
log.Error().Err(err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -111,7 +111,7 @@ CREATE TABLE client
|
||||||
ssl BOOLEAN,
|
ssl BOOLEAN,
|
||||||
username TEXT,
|
username TEXT,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
settings TEXT
|
settings JSON
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE action
|
CREATE TABLE action
|
||||||
|
|
|
@ -37,4 +37,5 @@ const (
|
||||||
ActionTypeDelugeV1 ActionType = "DELUGE_V1"
|
ActionTypeDelugeV1 ActionType = "DELUGE_V1"
|
||||||
ActionTypeDelugeV2 ActionType = "DELUGE_V2"
|
ActionTypeDelugeV2 ActionType = "DELUGE_V2"
|
||||||
ActionTypeWatchFolder ActionType = "WATCH_FOLDER"
|
ActionTypeWatchFolder ActionType = "WATCH_FOLDER"
|
||||||
|
ActionTypeRadarr ActionType = "RADARR"
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,6 +18,18 @@ type DownloadClient struct {
|
||||||
SSL bool `json:"ssl"`
|
SSL bool `json:"ssl"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Settings DownloadClientSettings `json:"settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadClientSettings struct {
|
||||||
|
APIKey string `json:"apikey,omitempty"`
|
||||||
|
Basic BasicAuth `json:"basic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasicAuth struct {
|
||||||
|
Auth bool `json:"auth,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadClientType string
|
type DownloadClientType string
|
||||||
|
@ -26,4 +38,5 @@ const (
|
||||||
DownloadClientTypeQbittorrent DownloadClientType = "QBITTORRENT"
|
DownloadClientTypeQbittorrent DownloadClientType = "QBITTORRENT"
|
||||||
DownloadClientTypeDelugeV1 DownloadClientType = "DELUGE_V1"
|
DownloadClientTypeDelugeV1 DownloadClientType = "DELUGE_V1"
|
||||||
DownloadClientTypeDelugeV2 DownloadClientType = "DELUGE_V2"
|
DownloadClientTypeDelugeV2 DownloadClientType = "DELUGE_V2"
|
||||||
|
DownloadClientTypeRadarr DownloadClientType = "RADARR"
|
||||||
)
|
)
|
||||||
|
|
108
internal/download_client/connection.go
Normal file
108
internal/download_client/connection.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
package download_client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
"github.com/autobrr/autobrr/pkg/qbittorrent"
|
||||||
|
"github.com/autobrr/autobrr/pkg/radarr"
|
||||||
|
|
||||||
|
delugeClient "github.com/gdm85/go-libdeluge"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *service) testConnection(client domain.DownloadClient) error {
|
||||||
|
switch client.Type {
|
||||||
|
case domain.DownloadClientTypeQbittorrent:
|
||||||
|
return s.testQbittorrentConnection(client)
|
||||||
|
|
||||||
|
case domain.DownloadClientTypeDelugeV1, domain.DownloadClientTypeDelugeV2:
|
||||||
|
return s.testDelugeConnection(client)
|
||||||
|
|
||||||
|
case domain.DownloadClientTypeRadarr:
|
||||||
|
return s.testRadarrConnection(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) testQbittorrentConnection(client domain.DownloadClient) error {
|
||||||
|
qbtSettings := qbittorrent.Settings{
|
||||||
|
Hostname: client.Host,
|
||||||
|
Port: uint(client.Port),
|
||||||
|
Username: client.Username,
|
||||||
|
Password: client.Password,
|
||||||
|
SSL: client.SSL,
|
||||||
|
}
|
||||||
|
|
||||||
|
qbt := qbittorrent.NewClient(qbtSettings)
|
||||||
|
err := qbt.Login()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("error logging into client: %v", client.Host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) testDelugeConnection(client domain.DownloadClient) error {
|
||||||
|
var deluge delugeClient.DelugeClient
|
||||||
|
|
||||||
|
settings := delugeClient.Settings{
|
||||||
|
Hostname: client.Host,
|
||||||
|
Port: uint(client.Port),
|
||||||
|
Login: client.Username,
|
||||||
|
Password: client.Password,
|
||||||
|
DebugServerResponses: true,
|
||||||
|
ReadWriteTimeout: time.Second * 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch client.Type {
|
||||||
|
case "DELUGE_V1":
|
||||||
|
deluge = delugeClient.NewV1(settings)
|
||||||
|
|
||||||
|
case "DELUGE_V2":
|
||||||
|
deluge = delugeClient.NewV2(settings)
|
||||||
|
|
||||||
|
default:
|
||||||
|
deluge = delugeClient.NewV2(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform connection to Deluge server
|
||||||
|
err := deluge.Connect()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("error logging into client: %v", client.Host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer deluge.Close()
|
||||||
|
|
||||||
|
// print daemon version
|
||||||
|
ver, err := deluge.DaemonVersion()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("could not get daemon version: %v", client.Host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msgf("daemon version: %v", ver)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) testRadarrConnection(client domain.DownloadClient) error {
|
||||||
|
r := radarr.New(radarr.Config{
|
||||||
|
Hostname: client.Host,
|
||||||
|
APIKey: client.Settings.APIKey,
|
||||||
|
BasicAuth: client.Settings.Basic.Auth,
|
||||||
|
Username: client.Settings.Basic.Username,
|
||||||
|
Password: client.Settings.Basic.Password,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := r.Test()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr: connection test failed: %v", client.Host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -2,11 +2,9 @@ package download_client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/autobrr/autobrr/pkg/qbittorrent"
|
|
||||||
delugeClient "github.com/gdm85/go-libdeluge"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -91,77 +89,3 @@ func (s *service) Test(client domain.DownloadClient) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) testConnection(client domain.DownloadClient) error {
|
|
||||||
switch client.Type {
|
|
||||||
case domain.DownloadClientTypeQbittorrent:
|
|
||||||
return s.testQbittorrentConnection(client)
|
|
||||||
case domain.DownloadClientTypeDelugeV1, domain.DownloadClientTypeDelugeV2:
|
|
||||||
return s.testDelugeConnection(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *service) testQbittorrentConnection(client domain.DownloadClient) error {
|
|
||||||
qbtSettings := qbittorrent.Settings{
|
|
||||||
Hostname: client.Host,
|
|
||||||
Port: uint(client.Port),
|
|
||||||
Username: client.Username,
|
|
||||||
Password: client.Password,
|
|
||||||
SSL: client.SSL,
|
|
||||||
}
|
|
||||||
|
|
||||||
qbt := qbittorrent.NewClient(qbtSettings)
|
|
||||||
err := qbt.Login()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msgf("error logging into client: %v", client.Host)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *service) testDelugeConnection(client domain.DownloadClient) error {
|
|
||||||
var deluge delugeClient.DelugeClient
|
|
||||||
|
|
||||||
settings := delugeClient.Settings{
|
|
||||||
Hostname: client.Host,
|
|
||||||
Port: uint(client.Port),
|
|
||||||
Login: client.Username,
|
|
||||||
Password: client.Password,
|
|
||||||
DebugServerResponses: true,
|
|
||||||
ReadWriteTimeout: time.Second * 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch client.Type {
|
|
||||||
case "DELUGE_V1":
|
|
||||||
deluge = delugeClient.NewV1(settings)
|
|
||||||
|
|
||||||
case "DELUGE_V2":
|
|
||||||
deluge = delugeClient.NewV2(settings)
|
|
||||||
|
|
||||||
default:
|
|
||||||
deluge = delugeClient.NewV2(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// perform connection to Deluge server
|
|
||||||
err := deluge.Connect()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msgf("error logging into client: %v", client.Host)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer deluge.Close()
|
|
||||||
|
|
||||||
// print daemon version
|
|
||||||
ver, err := deluge.DaemonVersion()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msgf("could not get daemon version: %v", client.Host)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().Msgf("daemon version: %v", ver)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
82
pkg/radarr/client.go
Normal file
82
pkg/radarr/client.go
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
package radarr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *client) get(endpoint string) (*http.Response, error) {
|
||||||
|
reqUrl := fmt.Sprintf("%v/api/v3/%v", c.config.Hostname, endpoint)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, reqUrl, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr client request error : %v", reqUrl)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.BasicAuth {
|
||||||
|
req.SetBasicAuth(c.config.Username, c.config.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("X-Api-Key", c.config.APIKey)
|
||||||
|
req.Header.Set("User-Agent", "autobrr")
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr client request error : %v", reqUrl)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode == http.StatusUnauthorized {
|
||||||
|
return nil, errors.New("unauthorized: bad credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) post(endpoint string, data interface{}) (*http.Response, error) {
|
||||||
|
reqUrl := fmt.Sprintf("%v/api/v3/%v", c.config.Hostname, endpoint)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr client could not marshal data: %v", reqUrl)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr client request error: %v", reqUrl)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.BasicAuth {
|
||||||
|
req.SetBasicAuth(c.config.Username, c.config.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("X-Api-Key", c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
req.Header.Set("User-Agent", "autobrr")
|
||||||
|
|
||||||
|
res, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msgf("radarr client request error: %v", reqUrl)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate response
|
||||||
|
if res.StatusCode == http.StatusUnauthorized {
|
||||||
|
log.Error().Err(err).Msgf("radarr client bad request: %v", reqUrl)
|
||||||
|
return nil, errors.New("unauthorized: bad credentials")
|
||||||
|
} else if res.StatusCode != http.StatusOK {
|
||||||
|
log.Error().Err(err).Msgf("radarr client request error: %v", reqUrl)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// return raw response and let the caller handle json unmarshal of body
|
||||||
|
return res, nil
|
||||||
|
}
|
130
pkg/radarr/radarr.go
Normal file
130
pkg/radarr/radarr.go
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
package radarr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Hostname string
|
||||||
|
APIKey string
|
||||||
|
|
||||||
|
// basic auth username and password
|
||||||
|
BasicAuth bool
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
Test() (*SystemStatusResponse, error)
|
||||||
|
Push(release Release) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
config Config
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(config Config) Client {
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: time.Second * 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &client{
|
||||||
|
config: config,
|
||||||
|
http: httpClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
type Release struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
DownloadUrl string `json:"downloadUrl"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Indexer string `json:"indexer"`
|
||||||
|
DownloadProtocol string `json:"downloadProtocol"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
PublishDate string `json:"publishDate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushResponse struct {
|
||||||
|
Approved bool `json:"approved"`
|
||||||
|
Rejected bool `json:"rejected"`
|
||||||
|
TempRejected bool `json:"temporarilyRejected"`
|
||||||
|
Rejections []string `json:"rejections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemStatusResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) Test() (*SystemStatusResponse, error) {
|
||||||
|
res, err := c.get("system/status")
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("radarr client get error")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("radarr client error reading body")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := SystemStatusResponse{}
|
||||||
|
err = json.Unmarshal(body, &response)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("radarr client error json unmarshal")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Msgf("radarr system/status response: %+v", response)
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) Push(release Release) error {
|
||||||
|
res, err := c.post("release/push", release)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("radarr client post error")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("radarr client error reading body")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pushResponse := make([]PushResponse, 0)
|
||||||
|
err = json.Unmarshal(body, &pushResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("radarr client error json unmarshal")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Msgf("radarr release/push response body: %+v", string(body))
|
||||||
|
|
||||||
|
// log and return if rejected
|
||||||
|
if pushResponse[0].Rejected {
|
||||||
|
rejections := strings.Join(pushResponse[0].Rejections, ", ")
|
||||||
|
|
||||||
|
log.Trace().Msgf("radarr push rejected: %s - reasons: %q", release.Title, rejections)
|
||||||
|
return errors.New(fmt.Errorf("radarr push rejected %v", rejections).Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
183
pkg/radarr/radarr_test.go
Normal file
183
pkg/radarr/radarr_test.go
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
package radarr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_client_Push(t *testing.T) {
|
||||||
|
// disable logger
|
||||||
|
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
key := "mock-key"
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/v3/release/push", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// request validation logic
|
||||||
|
apiKey := r.Header.Get("X-Api-Key")
|
||||||
|
if apiKey != "" {
|
||||||
|
if apiKey != key {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read json response
|
||||||
|
jsonPayload, _ := ioutil.ReadFile("testdata/release_push_response.json")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(jsonPayload)
|
||||||
|
})
|
||||||
|
|
||||||
|
type fields struct {
|
||||||
|
config Config
|
||||||
|
}
|
||||||
|
type args struct {
|
||||||
|
release Release
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
args args
|
||||||
|
err error
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "push",
|
||||||
|
fields: fields{
|
||||||
|
config: Config{
|
||||||
|
Hostname: ts.URL,
|
||||||
|
APIKey: "",
|
||||||
|
BasicAuth: false,
|
||||||
|
Username: "",
|
||||||
|
Password: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: args{release: Release{
|
||||||
|
Title: "Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP",
|
||||||
|
DownloadUrl: "https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent",
|
||||||
|
Size: 0,
|
||||||
|
Indexer: "test",
|
||||||
|
DownloadProtocol: "torrent",
|
||||||
|
Protocol: "torrent",
|
||||||
|
PublishDate: "2021-08-21T15:36:00Z",
|
||||||
|
}},
|
||||||
|
err: errors.New("radarr push rejected Could not find Some Old Movie"),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "push_error",
|
||||||
|
fields: fields{
|
||||||
|
config: Config{
|
||||||
|
Hostname: ts.URL,
|
||||||
|
APIKey: key,
|
||||||
|
BasicAuth: false,
|
||||||
|
Username: "",
|
||||||
|
Password: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: args{release: Release{
|
||||||
|
Title: "Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP",
|
||||||
|
DownloadUrl: "https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent",
|
||||||
|
Size: 0,
|
||||||
|
Indexer: "test",
|
||||||
|
DownloadProtocol: "torrent",
|
||||||
|
Protocol: "torrent",
|
||||||
|
PublishDate: "2021-08-21T15:36:00Z",
|
||||||
|
}},
|
||||||
|
err: errors.New("radarr push rejected Could not find Some Old Movie"),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c := New(tt.fields.config)
|
||||||
|
|
||||||
|
err := c.Push(tt.args.release)
|
||||||
|
if tt.wantErr && assert.Error(t, err) {
|
||||||
|
assert.Equal(t, tt.err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_client_Test(t *testing.T) {
|
||||||
|
// disable logger
|
||||||
|
zerolog.SetGlobalLevel(zerolog.Disabled)
|
||||||
|
|
||||||
|
key := "mock-key"
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apiKey := r.Header.Get("X-Api-Key")
|
||||||
|
if apiKey != "" {
|
||||||
|
if apiKey != key {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonPayload, _ := ioutil.ReadFile("testdata/system_status_response.json")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(jsonPayload)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
want *SystemStatusResponse
|
||||||
|
err error
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fetch",
|
||||||
|
cfg: Config{
|
||||||
|
Hostname: srv.URL,
|
||||||
|
APIKey: key,
|
||||||
|
BasicAuth: false,
|
||||||
|
Username: "",
|
||||||
|
Password: "",
|
||||||
|
},
|
||||||
|
want: &SystemStatusResponse{Version: "3.2.2.5080"},
|
||||||
|
err: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch_unauthorized",
|
||||||
|
cfg: Config{
|
||||||
|
Hostname: srv.URL,
|
||||||
|
APIKey: "bad-mock-key",
|
||||||
|
BasicAuth: false,
|
||||||
|
Username: "",
|
||||||
|
Password: "",
|
||||||
|
},
|
||||||
|
want: nil,
|
||||||
|
wantErr: true,
|
||||||
|
err: errors.New("unauthorized: bad credentials"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c := New(tt.cfg)
|
||||||
|
|
||||||
|
got, err := c.Test()
|
||||||
|
if tt.wantErr && assert.Error(t, err) {
|
||||||
|
assert.Equal(t, tt.err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
54
pkg/radarr/testdata/release_push_response.json
vendored
Normal file
54
pkg/radarr/testdata/release_push_response.json
vendored
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"guid": "PUSH-https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent",
|
||||||
|
"quality": {
|
||||||
|
"quality": {
|
||||||
|
"id": 30,
|
||||||
|
"name": "Remux-1080p",
|
||||||
|
"source": "bluray",
|
||||||
|
"resolution": 1080,
|
||||||
|
"modifier": "remux"
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"version": 1,
|
||||||
|
"real": 0,
|
||||||
|
"isRepack": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customFormats": [],
|
||||||
|
"customFormatScore": 0,
|
||||||
|
"qualityWeight": 1901,
|
||||||
|
"age": 0,
|
||||||
|
"ageHours": 0.028290299305555554,
|
||||||
|
"ageMinutes": 1.69741874,
|
||||||
|
"size": 0,
|
||||||
|
"indexerId": 0,
|
||||||
|
"indexer": "test",
|
||||||
|
"releaseGroup": "NOGROUP",
|
||||||
|
"releaseHash": "",
|
||||||
|
"title": "Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP",
|
||||||
|
"sceneSource": false,
|
||||||
|
"movieTitle": "Twister",
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "English"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"approved": false,
|
||||||
|
"temporarilyRejected": false,
|
||||||
|
"rejected": true,
|
||||||
|
"tmdbId": 0,
|
||||||
|
"imdbId": 0,
|
||||||
|
"rejections": [
|
||||||
|
"Could not find Some Old Movie"
|
||||||
|
],
|
||||||
|
"publishDate": "2021-08-21T15:36:00Z",
|
||||||
|
"downloadUrl": "https://www.test.org/rss/download/0000001/00000000000000000000/Some.Old.Movie.1996.Remastered.1080p.BluRay.REMUX.AVC.MULTI.TrueHD.Atmos.7.1-NOGROUP.torrent",
|
||||||
|
"downloadAllowed": false,
|
||||||
|
"releaseWeight": 0,
|
||||||
|
"indexerFlags": [],
|
||||||
|
"edition": "Remastered",
|
||||||
|
"protocol": "torrent"
|
||||||
|
}
|
||||||
|
]
|
28
pkg/radarr/testdata/system_status_response.json
vendored
Normal file
28
pkg/radarr/testdata/system_status_response.json
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"version": "3.2.2.5080",
|
||||||
|
"buildTime": "2021-06-03T11:51:33Z",
|
||||||
|
"isDebug": false,
|
||||||
|
"isProduction": true,
|
||||||
|
"isAdmin": false,
|
||||||
|
"isUserInteractive": true,
|
||||||
|
"startupPath": "/opt/Radarr",
|
||||||
|
"appData": "/home/test/.config/Radarr",
|
||||||
|
"osName": "debian",
|
||||||
|
"osVersion": "10",
|
||||||
|
"isNetCore": true,
|
||||||
|
"isMono": false,
|
||||||
|
"isLinux": true,
|
||||||
|
"isOsx": false,
|
||||||
|
"isWindows": false,
|
||||||
|
"isDocker": false,
|
||||||
|
"mode": "console",
|
||||||
|
"branch": "master",
|
||||||
|
"authentication": "none",
|
||||||
|
"sqliteVersion": "3.27.2",
|
||||||
|
"migrationVersion": 195,
|
||||||
|
"urlBase": "/radarr",
|
||||||
|
"runtimeVersion": "5.0.5",
|
||||||
|
"runtimeName": "netCore",
|
||||||
|
"startTime": "2021-08-20T20:49:42Z",
|
||||||
|
"packageUpdateMechanism": "builtIn"
|
||||||
|
}
|
|
@ -1,148 +1,60 @@
|
||||||
import { Action, DownloadClient } from "../domain/interfaces";
|
import { Action, DownloadClient } from "../domain/interfaces";
|
||||||
import React, {Fragment, useEffect, useRef } from "react";
|
import { Fragment, useEffect, useRef } from "react";
|
||||||
import {Dialog, Listbox, Switch, Transition} from '@headlessui/react'
|
import { Dialog, Listbox, Switch, Transition } from "@headlessui/react";
|
||||||
import { classNames } from "../styles/utils";
|
import { classNames } from "../styles/utils";
|
||||||
import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid";
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
SelectorIcon,
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
import { useToggle } from "../hooks/hooks";
|
import { useToggle } from "../hooks/hooks";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { Field, Form } from "react-final-form";
|
import { Field, Form } from "react-final-form";
|
||||||
import { TextField } from "./inputs";
|
import { TextField } from "./inputs";
|
||||||
|
import { NumberField, SelectField } from "./inputs/compact";
|
||||||
import DEBUG from "./debug";
|
import DEBUG from "./debug";
|
||||||
import APIClient from "../api/APIClient";
|
import APIClient from "../api/APIClient";
|
||||||
import { queryClient } from "../App";
|
import { queryClient } from "../App";
|
||||||
import {ActionTypeNameMap, ActionTypeOptions, DownloadClientTypeNameMap} from "../domain/constants";
|
import { ActionTypeNameMap, ActionTypeOptions } from "../domain/constants";
|
||||||
|
import { AlertWarning } from "./alerts";
|
||||||
|
import { DeleteModal } from "./modals";
|
||||||
|
|
||||||
interface FilterListProps {
|
interface DownloadClientSelectProps {
|
||||||
actions: Action[];
|
name: string;
|
||||||
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;
|
action: Action;
|
||||||
clients: DownloadClient[];
|
clients: DownloadClient[];
|
||||||
filterID: number;
|
|
||||||
idx: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
function DownloadClientSelect({
|
||||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
|
name,
|
||||||
const [edit, toggleEdit] = useToggle(false)
|
action,
|
||||||
|
clients,
|
||||||
const deleteMutation = useMutation((actionID: number) => APIClient.actions.delete(actionID), {
|
}: DownloadClientSelectProps) {
|
||||||
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 (
|
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">
|
<div className="col-span-6 sm:col-span-6">
|
||||||
<Field
|
<Field
|
||||||
name="client_id"
|
name={name}
|
||||||
type="select"
|
type="select"
|
||||||
render={({ input }) => (
|
render={({ input }) => (
|
||||||
<Listbox value={input.value} onChange={input.onChange}>
|
<Listbox value={input.value} onChange={input.onChange}>
|
||||||
{({ open }) => (
|
{({ open }) => (
|
||||||
<>
|
<>
|
||||||
<Listbox.Label
|
<Listbox.Label className="block text-xs font-bold text-gray-700 uppercase tracking-wide">
|
||||||
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Client</Listbox.Label>
|
Client
|
||||||
|
</Listbox.Label>
|
||||||
<div className="mt-2 relative">
|
<div className="mt-2 relative">
|
||||||
<Listbox.Button
|
<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">
|
||||||
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">
|
||||||
<span
|
{input.value
|
||||||
className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</span>
|
? clients.find((c) => c.id === input.value)!.name
|
||||||
|
: "Choose a client"}
|
||||||
|
</span>
|
||||||
{/*<span className="block truncate">Choose a client</span>*/}
|
{/*<span className="block truncate">Choose a client</span>*/}
|
||||||
<span
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
<SelectorIcon
|
||||||
<SelectorIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
|
className="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Listbox.Button>
|
</Listbox.Button>
|
||||||
|
|
||||||
|
@ -157,31 +69,43 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
static
|
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"
|
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) => (
|
{clients
|
||||||
|
.filter((c) => c.type === action.type)
|
||||||
|
.map((client: any) => (
|
||||||
<Listbox.Option
|
<Listbox.Option
|
||||||
key={client.id}
|
key={client.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
classNames(
|
classNames(
|
||||||
active ? 'text-white bg-indigo-600' : 'text-gray-900',
|
active
|
||||||
'cursor-default select-none relative py-2 pl-3 pr-9'
|
? "text-white bg-indigo-600"
|
||||||
|
: "text-gray-900",
|
||||||
|
"cursor-default select-none relative py-2 pl-3 pr-9"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
value={client.id}
|
value={client.id}
|
||||||
>
|
>
|
||||||
{({ selected, active }) => (
|
{({ selected, active }) => (
|
||||||
<>
|
<>
|
||||||
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
|
<span
|
||||||
|
className={classNames(
|
||||||
|
selected ? "font-semibold" : "font-normal",
|
||||||
|
"block truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{client.name}
|
{client.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
active ? 'text-white' : 'text-indigo-600',
|
active ? "text-white" : "text-indigo-600",
|
||||||
'absolute inset-y-0 right-0 flex items-center pr-4'
|
"absolute inset-y-0 right-0 flex items-center pr-4"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
|
<CheckIcon
|
||||||
|
className="h-5 w-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
@ -194,8 +118,148 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Listbox>
|
</Listbox>
|
||||||
)}/>
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AlertWarning
|
||||||
|
title="Notice"
|
||||||
|
text="The test action does nothing except to show if the filter works."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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">
|
||||||
|
<DownloadClientSelect
|
||||||
|
name="client_id"
|
||||||
|
action={action}
|
||||||
|
clients={clients}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="col-span-6 sm:col-span-6">
|
<div className="col-span-6 sm:col-span-6">
|
||||||
<TextField name="save_path" label="Save path" columns={6} />
|
<TextField name="save_path" label="Save path" columns={6} />
|
||||||
|
@ -208,126 +272,28 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<div className="col-span-12 sm:col-span-6">
|
<NumberField
|
||||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
name="limit_download_speed"
|
||||||
Limit upload speed (kb/s)
|
label="Limit download 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 &&
|
<NumberField
|
||||||
<span>{meta.error}</span>}
|
name="limit_upload_speed"
|
||||||
</div>
|
label="Limit upload speed (KB/s)"
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
case "DELUGE_V1":
|
case "DELUGE_V1":
|
||||||
case "DELUGE_V2":
|
case "DELUGE_V2":
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<div className="col-span-12 sm:col-span-6">
|
<DownloadClientSelect
|
||||||
<Field
|
|
||||||
name="client_id"
|
name="client_id"
|
||||||
type="select"
|
action={action}
|
||||||
render={({input}) => (
|
clients={clients}
|
||||||
<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">
|
<div className="col-span-12 sm:col-span-6">
|
||||||
<TextField name="save_path" label="Save path" columns={6} />
|
<TextField name="save_path" label="Save path" columns={6} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -338,71 +304,55 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<div className="col-span-12 sm:col-span-6">
|
<NumberField
|
||||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
name="limit_download_speed"
|
||||||
Limit upload speed (kb/s)
|
label="Limit download 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 &&
|
<NumberField
|
||||||
<span>{meta.error}</span>}
|
name="limit_upload_speed"
|
||||||
</div>
|
label="Limit upload speed (KB/s)"
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
|
);
|
||||||
|
case "RADARR":
|
||||||
|
return (
|
||||||
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
|
<DownloadClientSelect
|
||||||
|
name="client_id"
|
||||||
|
action={action}
|
||||||
|
clients={clients}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <p>default</p>
|
return null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={action.id}>
|
<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")}>
|
<div
|
||||||
|
className={classNames(
|
||||||
|
idx % 2 === 0 ? "bg-white" : "bg-gray-50",
|
||||||
|
"flex items-center sm:px-6 hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={action.enabled}
|
checked={action.enabled}
|
||||||
onChange={toggleActive}
|
onChange={toggleActive}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
action.enabled ? 'bg-teal-500' : 'bg-gray-200',
|
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'
|
"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 className="sr-only">Use setting</span>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
action.enabled ? 'translate-x-5' : 'translate-x-0',
|
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'
|
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
@ -410,101 +360,47 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="flex text-sm">
|
<div className="flex text-sm">
|
||||||
<p className="ml-4 font-medium text-indigo-600 truncate">{action.name}</p>
|
<p className="ml-4 font-medium text-indigo-600 truncate">
|
||||||
|
{action.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
|
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
|
||||||
<div className="flex overflow-hidden -space-x-1">
|
<div className="flex overflow-hidden -space-x-1">
|
||||||
<span className="text-sm font-normal text-gray-500">{ActionTypeNameMap[action.type]}</span>
|
<span className="text-sm font-normal text-gray-500">
|
||||||
|
{ActionTypeNameMap[action.type]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 flex-shrink-0">
|
<div className="ml-5 flex-shrink-0">
|
||||||
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
|
<ChevronRightIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{edit &&
|
{edit && (
|
||||||
<div className="px-4 py-4 flex items-center sm:px-6">
|
<div className="px-4 py-4 flex items-center sm:px-6">
|
||||||
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
|
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
|
||||||
<Dialog
|
<Dialog
|
||||||
as="div"
|
as="div"
|
||||||
static
|
static
|
||||||
className="fixed z-10 inset-0 overflow-y-auto"
|
className="fixed inset-0 overflow-y-auto"
|
||||||
initialFocus={cancelButtonRef}
|
initialFocus={cancelButtonRef}
|
||||||
open={deleteModalIsOpen}
|
open={deleteModalIsOpen}
|
||||||
onClose={toggleDeleteModal}
|
onClose={toggleDeleteModal}
|
||||||
>
|
>
|
||||||
<div
|
<DeleteModal
|
||||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
isOpen={deleteModalIsOpen}
|
||||||
<Transition.Child
|
buttonRef={cancelButtonRef}
|
||||||
as={Fragment}
|
toggle={toggleDeleteModal}
|
||||||
enter="ease-out duration-300"
|
deleteAction={deleteAction}
|
||||||
enterFrom="opacity-0"
|
title="Remove filter action"
|
||||||
enterTo="opacity-100"
|
text="Are you sure you want to remove this action? This action cannot be undone."
|
||||||
leave="ease-in duration-200"
|
/>
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
{/* This element is to trick the browser into centering the modal contents. */}
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
|
||||||
​
|
|
||||||
</span>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div
|
|
||||||
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true"/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
|
||||||
<Dialog.Title as="h3"
|
|
||||||
className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Remove filter action
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Are you sure you want to remove this action?
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
onClick={deleteAction}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
onClick={toggleDeleteModal}
|
|
||||||
ref={cancelButtonRef}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
|
|
||||||
|
@ -533,83 +429,15 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
{({ handleSubmit, values }) => {
|
{({ handleSubmit, values }) => {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="w-full">
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<div className="col-span-6">
|
<SelectField
|
||||||
|
|
||||||
<Field
|
|
||||||
name="type"
|
name="type"
|
||||||
type="select"
|
label="Type"
|
||||||
render={({input}) => (
|
optionDefaultText="Select yype"
|
||||||
<Listbox value={input.value} onChange={input.onChange}>
|
options={ActionTypeOptions}
|
||||||
{({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} />
|
<TextField name="name" label="Name" columns={6} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{TypeForm(values)}
|
{TypeForm(values)}
|
||||||
|
@ -643,12 +471,11 @@ function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||||
|
|
||||||
<DEBUG values={values} />
|
<DEBUG values={values} />
|
||||||
</form>
|
</form>
|
||||||
|
);
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
1
web/src/components/alerts/index.ts
Normal file
1
web/src/components/alerts/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default as AlertWarning } from "./warning";
|
32
web/src/components/alerts/warning.tsx
Normal file
32
web/src/components/alerts/warning.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { ExclamationIcon } from "@heroicons/react/solid";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface props {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertWarning({ title, text }: props) {
|
||||||
|
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">{title}</h3>
|
||||||
|
<div className="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>{text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertWarning;
|
|
@ -1,97 +0,0 @@
|
||||||
import {Field} from "react-final-form";
|
|
||||||
import {Listbox, Transition} from "@headlessui/react";
|
|
||||||
import {CheckIcon, SelectorIcon} from "@heroicons/react/solid";
|
|
||||||
import React, {Fragment} from "react";
|
|
||||||
import {classNames} from "../../styles/utils";
|
|
||||||
|
|
||||||
|
|
||||||
interface SelectOption {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface props {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
optionDefaultText: string;
|
|
||||||
options: SelectOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectField({name, label, optionDefaultText, options}: props) {
|
|
||||||
return (
|
|
||||||
<div className="col-span-6 sm:col-span-6">
|
|
||||||
<Field
|
|
||||||
name={name}
|
|
||||||
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">
|
|
||||||
<Listbox.Label
|
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">{label}</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 ? options.find(c => c.value === input.value)!.label : optionDefaultText}</span>
|
|
||||||
<span
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
|
||||||
<SelectorIcon className="h-5 w-5 text-gray-400" 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"
|
|
||||||
>
|
|
||||||
{options.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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Listbox>
|
|
||||||
)}/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SelectField;
|
|
47
web/src/components/inputs/compact/NumberField.tsx
Normal file
47
web/src/components/inputs/compact/NumberField.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
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 NumberField: React.FC<Props> = ({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
required,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<div className="col-span-12 sm:col-span-6">
|
||||||
|
<label htmlFor={name} className="block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Field name={name} parse={(v) => v & parseInt(v, 10)}>
|
||||||
|
{({ input, meta }) => (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...input}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NumberField;
|
111
web/src/components/inputs/compact/SelectField.tsx
Normal file
111
web/src/components/inputs/compact/SelectField.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { Field } from "react-final-form";
|
||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
import { CheckIcon, SelectorIcon } from "@heroicons/react/solid";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { classNames } from "../../../styles/utils";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface props {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
optionDefaultText: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ name, label, optionDefaultText, options }: props) {
|
||||||
|
return (
|
||||||
|
<div className="col-span-6">
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
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">
|
||||||
|
{label}
|
||||||
|
</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
|
||||||
|
? options.find((c) => c.value === input.value)!.label
|
||||||
|
: optionDefaultText}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<SelectorIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{options.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectField;
|
2
web/src/components/inputs/compact/index.ts
Normal file
2
web/src/components/inputs/compact/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as NumberField } from "./NumberField";
|
||||||
|
export { default as SelectField } from "./SelectField";
|
|
@ -5,4 +5,3 @@ export { default as TextAreaWide } from "./TextAreaWide";
|
||||||
export { default as MultiSelectField } from "./MultiSelectField";
|
export { default as MultiSelectField } from "./MultiSelectField";
|
||||||
export { default as RadioFieldset } from "./RadioFieldset";
|
export { default as RadioFieldset } from "./RadioFieldset";
|
||||||
export { default as SwitchGroup } from "./SwitchGroup";
|
export { default as SwitchGroup } from "./SwitchGroup";
|
||||||
export { default as SelectField } from "./SelectField";
|
|
||||||
|
|
54
web/src/components/inputs/wide/NumberField.tsx
Normal file
54
web/src/components/inputs/wide/NumberField.tsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
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 NumberFieldWide: 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}
|
||||||
|
parse={(v) => v & parseInt(v, 10)}
|
||||||
|
render={({ input, meta }) => (
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
id={name}
|
||||||
|
type="number"
|
||||||
|
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 NumberFieldWide;
|
105
web/src/components/inputs/wide/RadioFieldsetWide.tsx
Normal file
105
web/src/components/inputs/wide/RadioFieldsetWide.tsx
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { Field, useFormState } from "react-final-form";
|
||||||
|
import { RadioGroup } from "@headlessui/react";
|
||||||
|
import { classNames } from "../../../styles/utils";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { radioFieldsetOption } from "../RadioFieldset";
|
||||||
|
|
||||||
|
interface props {
|
||||||
|
name: string;
|
||||||
|
legend: string;
|
||||||
|
options: radioFieldsetOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioFieldsetWide({ name, legend, options }: props) {
|
||||||
|
const { values } = useFormState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
type="radio"
|
||||||
|
render={({ input }) => (
|
||||||
|
<RadioGroup value={values[name]} onChange={input.onChange}>
|
||||||
|
<RadioGroup.Label className="sr-only">
|
||||||
|
Privacy setting
|
||||||
|
</RadioGroup.Label>
|
||||||
|
<div className="bg-white rounded-md -space-y-px">
|
||||||
|
{options.map((setting, settingIdx) => (
|
||||||
|
<RadioGroup.Option
|
||||||
|
key={setting.value}
|
||||||
|
value={setting.value}
|
||||||
|
className={({ checked }) =>
|
||||||
|
classNames(
|
||||||
|
settingIdx === 0
|
||||||
|
? "rounded-tl-md rounded-tr-md"
|
||||||
|
: "",
|
||||||
|
settingIdx === options.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RadioFieldsetWide;
|
111
web/src/components/inputs/wide/SelectField.tsx
Normal file
111
web/src/components/inputs/wide/SelectField.tsx
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { Field } from "react-final-form";
|
||||||
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
import { CheckIcon, SelectorIcon } from "@heroicons/react/solid";
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
import { classNames } from "../../../styles/utils";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface props {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
optionDefaultText: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ name, label, optionDefaultText, options }: props) {
|
||||||
|
return (
|
||||||
|
<div className="col-span-6 sm:col-span-6">
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
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">
|
||||||
|
<Listbox.Label className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">
|
||||||
|
{label}
|
||||||
|
</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
|
||||||
|
? options.find((c) => c.value === input.value)!.label
|
||||||
|
: optionDefaultText}
|
||||||
|
</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<SelectorIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{options.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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectField;
|
3
web/src/components/inputs/wide/index.ts
Normal file
3
web/src/components/inputs/wide/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as NumberFieldWide } from "./NumberField";
|
||||||
|
export { default as RadioFieldsetWide } from "./RadioFieldsetWide";
|
||||||
|
export { default as SelectFieldWide } from "./SelectField";
|
|
@ -72,11 +72,13 @@ export const DownloadClientTypeOptions: radioFieldsetOption[] = [
|
||||||
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: DOWNLOAD_CLIENT_TYPES.qBittorrent},
|
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: DOWNLOAD_CLIENT_TYPES.qBittorrent},
|
||||||
{label: "Deluge", description: "Add torrents directly to Deluge", value: DOWNLOAD_CLIENT_TYPES.DelugeV1},
|
{label: "Deluge", description: "Add torrents directly to Deluge", value: DOWNLOAD_CLIENT_TYPES.DelugeV1},
|
||||||
{label: "Deluge 2", description: "Add torrents directly to Deluge 2", value: DOWNLOAD_CLIENT_TYPES.DelugeV2},
|
{label: "Deluge 2", description: "Add torrents directly to Deluge 2", value: DOWNLOAD_CLIENT_TYPES.DelugeV2},
|
||||||
|
{label: "Radarr", description: "Send to Radarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Radarr},
|
||||||
];
|
];
|
||||||
export const DownloadClientTypeNameMap = {
|
export const DownloadClientTypeNameMap = {
|
||||||
"DELUGE_V1": "Deluge v1",
|
"DELUGE_V1": "Deluge v1",
|
||||||
"DELUGE_V2": "Deluge v2",
|
"DELUGE_V2": "Deluge v2",
|
||||||
"QBITTORRENT": "qBittorrent"
|
"QBITTORRENT": "qBittorrent",
|
||||||
|
"RADARR": "Radarr",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActionTypeOptions: radioFieldsetOption[] = [
|
export const ActionTypeOptions: radioFieldsetOption[] = [
|
||||||
|
@ -86,6 +88,7 @@ export const ActionTypeOptions: radioFieldsetOption[] = [
|
||||||
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: "QBITTORRENT"},
|
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: "QBITTORRENT"},
|
||||||
{label: "Deluge", description: "Add torrents directly to Deluge", value: "DELUGE_V1"},
|
{label: "Deluge", description: "Add torrents directly to Deluge", value: "DELUGE_V1"},
|
||||||
{label: "Deluge v2", description: "Add torrents directly to Deluge 2", value: "DELUGE_V2"},
|
{label: "Deluge v2", description: "Add torrents directly to Deluge 2", value: "DELUGE_V2"},
|
||||||
|
{label: "Radarr", description: "Send to Radarr and let it decide", value: DOWNLOAD_CLIENT_TYPES.Radarr},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ActionTypeNameMap = {
|
export const ActionTypeNameMap = {
|
||||||
|
@ -94,5 +97,6 @@ export const ActionTypeNameMap = {
|
||||||
"EXEC": "Exec",
|
"EXEC": "Exec",
|
||||||
"DELUGE_V1": "Deluge v1",
|
"DELUGE_V1": "Deluge v1",
|
||||||
"DELUGE_V2": "Deluge v2",
|
"DELUGE_V2": "Deluge v2",
|
||||||
"QBITTORRENT": "qBittorrent"
|
"QBITTORRENT": "qBittorrent",
|
||||||
|
"RADARR": "Radarr"
|
||||||
};
|
};
|
||||||
|
|
|
@ -64,16 +64,17 @@ export interface Filter {
|
||||||
indexers: Indexer[];
|
indexers: Indexer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2';
|
export type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2' | 'RADARR';
|
||||||
export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE_V1', 'DELUGE_V2'];
|
export const ACTIONTYPES: ActionType[] = ['TEST', 'EXEC' , 'WATCH_FOLDER' , 'QBITTORRENT' , 'DELUGE_V1', 'DELUGE_V2', 'RADARR'];
|
||||||
|
|
||||||
|
|
||||||
export type DownloadClientType = 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2';
|
export type DownloadClientType = 'QBITTORRENT' | 'DELUGE_V1' | 'DELUGE_V2' | 'RADARR';
|
||||||
|
|
||||||
export enum DOWNLOAD_CLIENT_TYPES {
|
export enum DOWNLOAD_CLIENT_TYPES {
|
||||||
qBittorrent = 'QBITTORRENT',
|
qBittorrent = 'QBITTORRENT',
|
||||||
DelugeV1 = 'DELUGE_V1',
|
DelugeV1 = 'DELUGE_V1',
|
||||||
DelugeV2 = 'DELUGE_V2'
|
DelugeV2 = 'DELUGE_V2',
|
||||||
|
Radarr = 'RADARR'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadClient {
|
export interface DownloadClient {
|
||||||
|
|
|
@ -1,15 +1,126 @@
|
||||||
import React, {Fragment, useEffect } from "react";
|
import { Fragment, useEffect } from "react";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { Action, DownloadClient, Filter } from "../../domain/interfaces";
|
import { Action, DownloadClient, Filter } from "../../domain/interfaces";
|
||||||
import { queryClient } from "../../App";
|
import { queryClient } from "../../App";
|
||||||
import { sleep } from "../../utils/utils";
|
import { sleep } from "../../utils/utils";
|
||||||
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
|
import { CheckIcon, SelectorIcon, XIcon } from "@heroicons/react/solid";
|
||||||
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
|
import { Dialog, Listbox, Transition } from "@headlessui/react";
|
||||||
import { classNames } from "../../styles/utils";
|
import { classNames } from "../../styles/utils";
|
||||||
import { Field, Form } from "react-final-form";
|
import { Field, Form } from "react-final-form";
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import APIClient from "../../api/APIClient";
|
import APIClient from "../../api/APIClient";
|
||||||
import { ActionTypeOptions } from "../../domain/constants";
|
import { ActionTypeOptions } from "../../domain/constants";
|
||||||
|
import { TextFieldWide } from "../../components/inputs";
|
||||||
|
import { AlertWarning } from "../../components/alerts";
|
||||||
|
import {
|
||||||
|
NumberFieldWide,
|
||||||
|
RadioFieldsetWide,
|
||||||
|
} from "../../components/inputs/wide";
|
||||||
|
|
||||||
|
interface DownloadClientSelectProps {
|
||||||
|
name: string;
|
||||||
|
clients: DownloadClient[];
|
||||||
|
values: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadClientSelect({
|
||||||
|
name,
|
||||||
|
clients,
|
||||||
|
values,
|
||||||
|
}: DownloadClientSelectProps) {
|
||||||
|
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">
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface props {
|
interface props {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
@ -19,12 +130,15 @@ interface props {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterActionAddForm({ filter, isOpen, toggle, clients }: props) {
|
function FilterActionAddForm({ filter, isOpen, toggle, clients }: props) {
|
||||||
const mutation = useMutation((action: Action) => APIClient.actions.create(action), {
|
const mutation = useMutation(
|
||||||
|
(action: Action) => APIClient.actions.create(action),
|
||||||
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries(['filter', filter.id]);
|
queryClient.invalidateQueries(["filter", filter.id]);
|
||||||
sleep(500).then(() => toggle())
|
sleep(500).then(() => toggle());
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log("render add action form", clients)
|
// console.log("render add action form", clients)
|
||||||
|
@ -32,582 +146,145 @@ function FilterActionAddForm({filter, isOpen, toggle, clients}: props) {
|
||||||
|
|
||||||
const onSubmit = (data: any) => {
|
const onSubmit = (data: any) => {
|
||||||
// TODO clear data depending on type
|
// TODO clear data depending on type
|
||||||
mutation.mutate(data)
|
mutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TypeForm = (values: any) => {
|
const TypeForm = (values: any) => {
|
||||||
switch (values.type) {
|
switch (values.type) {
|
||||||
case "TEST":
|
case "TEST":
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<AlertWarning
|
||||||
<div className="rounded-md bg-yellow-50 p-4">
|
title="Notice"
|
||||||
<div className="flex">
|
text="The test action does nothing except to show if the filter works."
|
||||||
<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":
|
case "WATCH_FOLDER":
|
||||||
return (
|
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>
|
<div>
|
||||||
<label
|
<TextFieldWide
|
||||||
htmlFor="watch_folder"
|
name="watch_folder"
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
label="Watch dir"
|
||||||
>
|
|
||||||
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"
|
placeholder="Watch directory eg. /home/user/watch_folder"
|
||||||
/>
|
/>
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case "EXEC":
|
case "EXEC":
|
||||||
return (
|
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>
|
<div>
|
||||||
<label
|
<TextFieldWide
|
||||||
htmlFor="exec_cmd"
|
name="exec_cmd"
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
label="Program"
|
||||||
>
|
|
||||||
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"
|
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">
|
<TextFieldWide
|
||||||
<div>
|
name="exec_args"
|
||||||
<label
|
label="Arguments"
|
||||||
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"
|
placeholder="Arguments eg. --test"
|
||||||
/>
|
/>
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
)
|
|
||||||
case "QBITTORRENT":
|
case "QBITTORRENT":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<DownloadClientSelect
|
||||||
<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"
|
name="client_id"
|
||||||
type="select"
|
clients={clients}
|
||||||
render={({input}) => (
|
values={values}
|
||||||
<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">
|
<TextFieldWide name="category" label="Category" placeholder="" />
|
||||||
<div>
|
<TextFieldWide
|
||||||
<label
|
name="tags"
|
||||||
htmlFor="tags"
|
label="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"
|
placeholder="Comma separated eg. 4k,remux"
|
||||||
/>
|
/>
|
||||||
{meta.touched && meta.error &&
|
<TextFieldWide name="save_path" label="Save path" />
|
||||||
<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 className="divide-y divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
|
||||||
<div>
|
<div className="px-4">
|
||||||
<label
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
htmlFor="save_path"
|
Limit speeds
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
</h3>
|
||||||
>
|
|
||||||
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">
|
<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.
|
Limit download and upload speed for torrents in this filter.
|
||||||
|
In KB/s.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
|
<NumberFieldWide
|
||||||
<div className="pt-6 sm:pt-5">
|
name="limit_download_speed"
|
||||||
|
label="Limit download speed"
|
||||||
<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 &&
|
<NumberFieldWide
|
||||||
<span>{meta.error}</span>}
|
name="limit_upload_speed"
|
||||||
</div>
|
label="Limit upload speed"
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case "DELUGE_V1":
|
case "DELUGE_V1":
|
||||||
case "DELUGE_V2":
|
case "DELUGE_V2":
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/*TODO choose client*/}
|
<DownloadClientSelect
|
||||||
|
|
||||||
<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"
|
name="client_id"
|
||||||
type="select"
|
clients={clients}
|
||||||
render={({input}) => (
|
values={values}
|
||||||
<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">
|
<TextFieldWide name="label" label="Label" />
|
||||||
<div>
|
<TextFieldWide name="save_path" label="Save path" />
|
||||||
<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 className="divide-y divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
|
||||||
<div>
|
<div className="px-4">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Limit speeds</h3>
|
<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">
|
<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.
|
Limit download and upload speed for torrents in this filter.
|
||||||
|
In KB/s.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
|
<NumberFieldWide
|
||||||
<div className="pt-6 sm:pt-5">
|
name="limit_download_speed"
|
||||||
|
label="Limit download speed"
|
||||||
<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 &&
|
<NumberFieldWide
|
||||||
<span>{meta.error}</span>}
|
name="limit_upload_speed"
|
||||||
</div>
|
label="Limit upload speed"
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
case "RADARR":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DownloadClientSelect
|
||||||
|
name="client_id"
|
||||||
|
clients={clients}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<AlertWarning
|
||||||
<div className="rounded-md bg-yellow-50 p-4">
|
title="Notice"
|
||||||
<div className="flex">
|
text="The test action does nothing except to show if the filter works."
|
||||||
<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 (
|
return (
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
static
|
||||||
|
className="fixed inset-0 overflow-hidden"
|
||||||
|
open={isOpen}
|
||||||
|
onClose={toggle}
|
||||||
|
>
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<Dialog.Overlay className="absolute inset-0" />
|
<Dialog.Overlay className="absolute inset-0" />
|
||||||
|
|
||||||
|
@ -645,16 +322,17 @@ function FilterActionAddForm({filter, isOpen, toggle, clients}: props) {
|
||||||
>
|
>
|
||||||
{({ handleSubmit, values }) => {
|
{({ handleSubmit, values }) => {
|
||||||
return (
|
return (
|
||||||
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
|
<form
|
||||||
onSubmit={handleSubmit}>
|
className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* Header */}
|
|
||||||
<div className="px-4 py-6 bg-gray-50 sm:px-6">
|
<div className="px-4 py-6 bg-gray-50 sm:px-6">
|
||||||
<div className="flex items-start justify-between space-x-3">
|
<div className="flex items-start justify-between space-x-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Dialog.Title
|
<Dialog.Title className="text-lg font-medium text-gray-900">
|
||||||
className="text-lg font-medium text-gray-900">Add
|
Add action
|
||||||
action</Dialog.Title>
|
</Dialog.Title>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Add filter action.
|
Add filter action.
|
||||||
</p>
|
</p>
|
||||||
|
@ -666,122 +344,28 @@ function FilterActionAddForm({filter, isOpen, toggle, clients}: props) {
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close panel</span>
|
<span className="sr-only">Close panel</span>
|
||||||
<XIcon className="h-6 w-6" aria-hidden="true"/>
|
<XIcon
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<TextFieldWide name="name" label="Action name" />
|
||||||
className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
|
<RadioFieldsetWide
|
||||||
<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"
|
name="type"
|
||||||
type="radio"
|
legend="Type"
|
||||||
render={({input}) => (
|
options={ActionTypeOptions}
|
||||||
<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)}
|
{TypeForm(values)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
|
||||||
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
|
|
||||||
<div className="space-x-3 flex justify-end">
|
<div className="space-x-3 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -801,17 +385,16 @@ function FilterActionAddForm({filter, isOpen, toggle, clients}: props) {
|
||||||
|
|
||||||
<DEBUG values={values} />
|
<DEBUG values={values} />
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilterActionAddForm;
|
export default FilterActionAddForm;
|
|
@ -3,13 +3,19 @@ import {useMutation} from "react-query";
|
||||||
import { Action, DownloadClient, Filter } from "../../domain/interfaces";
|
import { Action, DownloadClient, Filter } from "../../domain/interfaces";
|
||||||
import { queryClient } from "../../App";
|
import { queryClient } from "../../App";
|
||||||
import { sleep } from "../../utils/utils";
|
import { sleep } from "../../utils/utils";
|
||||||
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
|
import { XIcon } from "@heroicons/react/solid";
|
||||||
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import {classNames} from "../../styles/utils";
|
import { Form } from "react-final-form";
|
||||||
import {Field, Form} from "react-final-form";
|
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import APIClient from "../../api/APIClient";
|
import APIClient from "../../api/APIClient";
|
||||||
import { ActionTypeOptions } from "../../domain/constants";
|
import { ActionTypeOptions } from "../../domain/constants";
|
||||||
|
import { AlertWarning } from "../../components/alerts";
|
||||||
|
import { TextFieldWide } from "../../components/inputs";
|
||||||
|
import {
|
||||||
|
NumberFieldWide,
|
||||||
|
RadioFieldsetWide,
|
||||||
|
} from "../../components/inputs/wide";
|
||||||
|
import { DownloadClientSelect } from "./FilterActionAddForm";
|
||||||
|
|
||||||
interface props {
|
interface props {
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
@ -19,16 +25,25 @@ interface props {
|
||||||
action: Action;
|
action: Action;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props) {
|
function FilterActionUpdateForm({
|
||||||
const mutation = useMutation((action: Action) => APIClient.actions.update(action), {
|
filter,
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
clients,
|
||||||
|
action,
|
||||||
|
}: props) {
|
||||||
|
const mutation = useMutation(
|
||||||
|
(action: Action) => APIClient.actions.update(action),
|
||||||
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// console.log("add action");
|
// console.log("add action");
|
||||||
queryClient.invalidateQueries(['filter', filter.id]);
|
queryClient.invalidateQueries(["filter", filter.id]);
|
||||||
sleep(1500)
|
sleep(1500);
|
||||||
|
|
||||||
toggle()
|
toggle();
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log("render add action form", clients)
|
// console.log("render add action form", clients)
|
||||||
|
@ -37,583 +52,146 @@ function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props
|
||||||
const onSubmit = (data: any) => {
|
const onSubmit = (data: any) => {
|
||||||
// TODO clear data depending on type
|
// TODO clear data depending on type
|
||||||
|
|
||||||
console.log(data)
|
console.log(data);
|
||||||
mutation.mutate(data)
|
mutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TypeForm = (values: any) => {
|
const TypeForm = (values: any) => {
|
||||||
switch (values.type) {
|
switch (values.type) {
|
||||||
case "TEST":
|
case "TEST":
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<AlertWarning
|
||||||
<div className="rounded-md bg-yellow-50 p-4">
|
title="Notice"
|
||||||
<div className="flex">
|
text="The test action does nothing except to show if the filter works."
|
||||||
<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":
|
case "WATCH_FOLDER":
|
||||||
return (
|
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>
|
<div>
|
||||||
<label
|
<TextFieldWide
|
||||||
htmlFor="watch_folder"
|
name="watch_folder"
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
label="Watch dir"
|
||||||
>
|
|
||||||
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"
|
placeholder="Watch directory eg. /home/user/watch_folder"
|
||||||
/>
|
/>
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case "EXEC":
|
case "EXEC":
|
||||||
return (
|
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>
|
<div>
|
||||||
<label
|
<TextFieldWide
|
||||||
htmlFor="exec_cmd"
|
name="exec_cmd"
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
label="Program"
|
||||||
>
|
|
||||||
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"
|
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">
|
<TextFieldWide
|
||||||
<div>
|
name="exec_args"
|
||||||
<label
|
label="Arguments"
|
||||||
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"
|
placeholder="Arguments eg. --test"
|
||||||
/>
|
/>
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
)
|
|
||||||
case "QBITTORRENT":
|
case "QBITTORRENT":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<DownloadClientSelect
|
||||||
<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"
|
name="client_id"
|
||||||
type="select"
|
clients={clients}
|
||||||
render={({input}) => (
|
values={values}
|
||||||
<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">
|
<TextFieldWide name="category" label="Category" placeholder="" />
|
||||||
<div>
|
<TextFieldWide
|
||||||
<label
|
name="tags"
|
||||||
htmlFor="tags"
|
label="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"
|
placeholder="Comma separated eg. 4k,remux"
|
||||||
/>
|
/>
|
||||||
{meta.touched && meta.error &&
|
<TextFieldWide name="save_path" label="Save path" />
|
||||||
<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 className="divide-y divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
|
||||||
<div>
|
<div className="px-4">
|
||||||
<label
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
htmlFor="save_path"
|
Limit speeds
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
</h3>
|
||||||
>
|
|
||||||
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">
|
<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.
|
Limit download and upload speed for torrents in this filter.
|
||||||
|
In KB/s.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
|
<NumberFieldWide
|
||||||
<div className="pt-6 sm:pt-5">
|
name="limit_download_speed"
|
||||||
|
label="Limit download speed"
|
||||||
<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 &&
|
<NumberFieldWide
|
||||||
<span>{meta.error}</span>}
|
name="limit_upload_speed"
|
||||||
</div>
|
label="Limit upload speed"
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
case "DELUGE_V1":
|
case "DELUGE_V1":
|
||||||
case "DELUGE_V2":
|
case "DELUGE_V2":
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/*TODO choose client*/}
|
<DownloadClientSelect
|
||||||
|
|
||||||
<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"
|
name="client_id"
|
||||||
type="select"
|
clients={clients}
|
||||||
render={({input}) => (
|
values={values}
|
||||||
<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">
|
<TextFieldWide name="label" label="Label" />
|
||||||
<div>
|
<TextFieldWide name="save_path" label="Save path" />
|
||||||
<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 className="divide-y divide-gray-200 pt-8 space-y-6 sm:pt-10 sm:space-y-5">
|
||||||
<div>
|
<div className="px-4">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Limit speeds</h3>
|
<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">
|
<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.
|
Limit download and upload speed for torrents in this filter.
|
||||||
|
In KB/s.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6 sm:space-y-5 divide-y divide-gray-200">
|
<NumberFieldWide
|
||||||
<div className="pt-6 sm:pt-5">
|
name="limit_download_speed"
|
||||||
|
label="Limit download speed"
|
||||||
<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 &&
|
<NumberFieldWide
|
||||||
<span>{meta.error}</span>}
|
name="limit_upload_speed"
|
||||||
</div>
|
label="Limit upload speed"
|
||||||
)}
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
case "RADARR":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DownloadClientSelect
|
||||||
|
name="client_id"
|
||||||
|
clients={clients}
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<AlertWarning
|
||||||
<div className="rounded-md bg-yellow-50 p-4">
|
title="Notice"
|
||||||
<div className="flex">
|
text="The test action does nothing except to show if the filter works."
|
||||||
<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 (
|
return (
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
<Transition.Root show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
static
|
||||||
|
className="fixed inset-0 overflow-hidden"
|
||||||
|
open={isOpen}
|
||||||
|
onClose={toggle}
|
||||||
|
>
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<Dialog.Overlay className="absolute inset-0" />
|
<Dialog.Overlay className="absolute inset-0" />
|
||||||
|
|
||||||
|
@ -651,15 +229,17 @@ function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props
|
||||||
>
|
>
|
||||||
{({ handleSubmit, values }) => {
|
{({ handleSubmit, values }) => {
|
||||||
return (
|
return (
|
||||||
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
|
<form
|
||||||
onSubmit={handleSubmit}>
|
className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* Header */}
|
|
||||||
<div className="px-4 py-6 bg-gray-50 sm:px-6">
|
<div className="px-4 py-6 bg-gray-50 sm:px-6">
|
||||||
<div className="flex items-start justify-between space-x-3">
|
<div className="flex items-start justify-between space-x-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Dialog.Title
|
<Dialog.Title className="text-lg font-medium text-gray-900">
|
||||||
className="text-lg font-medium text-gray-900">Update action</Dialog.Title>
|
Update action
|
||||||
|
</Dialog.Title>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Add filter action.
|
Add filter action.
|
||||||
</p>
|
</p>
|
||||||
|
@ -671,122 +251,28 @@ function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close panel</span>
|
<span className="sr-only">Close panel</span>
|
||||||
<XIcon className="h-6 w-6" aria-hidden="true"/>
|
<XIcon
|
||||||
|
className="h-6 w-6"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<TextFieldWide name="name" label="Action name" />
|
||||||
className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
|
<RadioFieldsetWide
|
||||||
<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"
|
name="type"
|
||||||
type="radio"
|
legend="Type"
|
||||||
render={({input}) => (
|
options={ActionTypeOptions}
|
||||||
<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)}
|
{TypeForm(values)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
|
||||||
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
|
|
||||||
<div className="space-x-3 flex justify-end">
|
<div className="space-x-3 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -806,17 +292,16 @@ function FilterActionUpdateForm({filter, isOpen, toggle, clients, action}: props
|
||||||
|
|
||||||
<DEBUG values={values} />
|
<DEBUG values={values} />
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilterActionUpdateForm;
|
export default FilterActionUpdateForm;
|
||||||
|
|
|
@ -2,8 +2,8 @@ export { default as FilterAddForm } from "./filters/FilterAddForm";
|
||||||
export { default as FilterActionAddForm } from "./filters/FilterActionAddForm";
|
export { default as FilterActionAddForm } from "./filters/FilterActionAddForm";
|
||||||
export { default as FilterActionUpdateForm } from "./filters/FilterActionUpdateForm";
|
export { default as FilterActionUpdateForm } from "./filters/FilterActionUpdateForm";
|
||||||
|
|
||||||
export { default as DownloadClientAddForm } from "./settings/DownloadClientAddForm";
|
export { default as DownloadClientAddForm } from "./settings/downloadclient/DownloadClientAddForm";
|
||||||
export { default as DownloadClientUpdateForm } from "./settings/DownloadClientUpdateForm";
|
export { default as DownloadClientUpdateForm } from "./settings/downloadclient/DownloadClientUpdateForm";
|
||||||
|
|
||||||
export { default as IndexerAddForm } from "./settings/IndexerAddForm";
|
export { default as IndexerAddForm } from "./settings/IndexerAddForm";
|
||||||
export { default as IndexerUpdateForm } from "./settings/IndexerUpdateForm";
|
export { default as IndexerUpdateForm } from "./settings/IndexerUpdateForm";
|
||||||
|
|
|
@ -1,397 +0,0 @@
|
||||||
import React, {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 "../../App";
|
|
||||||
import APIClient from "../../api/APIClient";
|
|
||||||
import {sleep} from "../../utils/utils";
|
|
||||||
import {DownloadClientTypeOptions} from "../../domain/constants";
|
|
||||||
|
|
||||||
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">Client
|
|
||||||
type</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;
|
|
|
@ -1,506 +0,0 @@
|
||||||
import {Fragment, useRef, useState} from "react";
|
|
||||||
import {useToggle} from "../../hooks/hooks";
|
|
||||||
import {useMutation} from "react-query";
|
|
||||||
import {DownloadClient} from "../../domain/interfaces";
|
|
||||||
import {queryClient} from "../../App";
|
|
||||||
import {Dialog, RadioGroup, Transition} from "@headlessui/react";
|
|
||||||
import {ExclamationIcon, XIcon} from "@heroicons/react/solid";
|
|
||||||
import {classNames} from "../../styles/utils";
|
|
||||||
import {Field, Form} from "react-final-form";
|
|
||||||
import DEBUG from "../../components/debug";
|
|
||||||
import {SwitchGroup} from "../../components/inputs";
|
|
||||||
import {DownloadClientTypeOptions} from "../../domain/constants";
|
|
||||||
import APIClient from "../../api/APIClient";
|
|
||||||
import {sleep} from "../../utils/utils";
|
|
||||||
|
|
||||||
function DownloadClientUpdateForm({client, isOpen, toggle}: any) {
|
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
|
||||||
const [isSuccessfulTest, setIsSuccessfulTest] = useState(false)
|
|
||||||
const [isErrorTest, setIsErrorTest] = useState(false)
|
|
||||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
|
|
||||||
|
|
||||||
const mutation = useMutation((client: DownloadClient) => APIClient.download_clients.update(client), {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries(['downloadClients']);
|
|
||||||
|
|
||||||
toggle()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation((clientID: number) => APIClient.download_clients.delete(clientID), {
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
toggleDeleteModal()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const testClientMutation = useMutation((client: DownloadClient) => APIClient.download_clients.test(client), {
|
|
||||||
onMutate: () => {
|
|
||||||
setIsTesting(true)
|
|
||||||
setIsErrorTest(false)
|
|
||||||
setIsSuccessfulTest(false)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
sleep(1000).then(() => {
|
|
||||||
setIsTesting(false)
|
|
||||||
setIsSuccessfulTest(true)
|
|
||||||
}).then(() => {
|
|
||||||
sleep(2500).then(() => {
|
|
||||||
setIsSuccessfulTest(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setIsTesting(false)
|
|
||||||
setIsErrorTest(true)
|
|
||||||
sleep(2500).then(() => {
|
|
||||||
setIsErrorTest(false)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = (data: any) => {
|
|
||||||
mutation.mutate(data)
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelButtonRef = useRef(null)
|
|
||||||
const cancelModalButtonRef = useRef(null)
|
|
||||||
|
|
||||||
const deleteAction = () => {
|
|
||||||
deleteMutation.mutate(client.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const testClient = (data: any) => {
|
|
||||||
testClientMutation.mutate(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}
|
|
||||||
initialFocus={cancelButtonRef}>
|
|
||||||
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
|
|
||||||
<Dialog
|
|
||||||
as="div"
|
|
||||||
static
|
|
||||||
className="fixed z-10 inset-0 overflow-y-auto"
|
|
||||||
initialFocus={cancelModalButtonRef}
|
|
||||||
open={deleteModalIsOpen}
|
|
||||||
onClose={toggleDeleteModal}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
{/* This element is to trick the browser into centering the modal contents. */}
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
|
||||||
​
|
|
||||||
</span>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div
|
|
||||||
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true"/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
|
||||||
<Dialog.Title as="h3"
|
|
||||||
className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Remove client
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Are you sure you want to remove this client?
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
onClick={deleteAction}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
onClick={toggleDeleteModal}
|
|
||||||
ref={cancelModalButtonRef}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<Dialog.Overlay className="absolute inset-0"/>
|
|
||||||
|
|
||||||
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
|
||||||
enterFrom="translate-x-full"
|
|
||||||
enterTo="translate-x-0"
|
|
||||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
|
||||||
leaveFrom="translate-x-0"
|
|
||||||
leaveTo="translate-x-full"
|
|
||||||
>
|
|
||||||
<div className="w-screen max-w-2xl">
|
|
||||||
|
|
||||||
<Form
|
|
||||||
initialValues={{
|
|
||||||
id: client.id,
|
|
||||||
name: client.name,
|
|
||||||
type: client.type,
|
|
||||||
enabled: client.enabled,
|
|
||||||
host: client.host,
|
|
||||||
port: client.port,
|
|
||||||
ssl: client.ssl,
|
|
||||||
username: client.username,
|
|
||||||
password: client.password
|
|
||||||
}}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
{({handleSubmit, values}) => {
|
|
||||||
return (
|
|
||||||
<form className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll"
|
|
||||||
onSubmit={handleSubmit}>
|
|
||||||
<div className="flex-1">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-4 py-6 bg-gray-50 sm:px-6">
|
|
||||||
<div className="flex items-start justify-between space-x-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Dialog.Title
|
|
||||||
className="text-lg font-medium text-gray-900">Edit
|
|
||||||
client</Dialog.Title>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Edit download client settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-7 flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<span className="sr-only">Close panel</span>
|
|
||||||
<XIcon className="h-6 w-6" aria-hidden="true"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
|
|
||||||
<div
|
|
||||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="name"
|
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Field name="name">
|
|
||||||
{({input, meta}) => (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...input}
|
|
||||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
|
||||||
/>
|
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
|
|
||||||
<SwitchGroup name="enabled" label="Enabled"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<div
|
|
||||||
className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
|
|
||||||
<div>
|
|
||||||
<legend
|
|
||||||
className="text-sm font-medium text-gray-900">Type
|
|
||||||
</legend>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5 sm:col-span-2">
|
|
||||||
<div className="space-y-5 sm:mt-0">
|
|
||||||
<Field
|
|
||||||
name="type"
|
|
||||||
type="radio"
|
|
||||||
render={({input}) => (
|
|
||||||
<RadioGroup value={values.type}
|
|
||||||
onChange={input.onChange}>
|
|
||||||
<RadioGroup.Label
|
|
||||||
className="sr-only">Privacy
|
|
||||||
setting</RadioGroup.Label>
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-md -space-y-px">
|
|
||||||
{DownloadClientTypeOptions.map((setting, settingIdx) => (
|
|
||||||
<RadioGroup.Option
|
|
||||||
key={setting.value}
|
|
||||||
value={setting.value}
|
|
||||||
className={({checked}) =>
|
|
||||||
classNames(
|
|
||||||
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
|
|
||||||
settingIdx === DownloadClientTypeOptions.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
|
|
||||||
checked ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200',
|
|
||||||
'relative border p-4 flex cursor-pointer focus:outline-none'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({
|
|
||||||
active,
|
|
||||||
checked
|
|
||||||
}) => (
|
|
||||||
<Fragment>
|
|
||||||
<span
|
|
||||||
className={classNames(
|
|
||||||
checked ? 'bg-indigo-600 border-transparent' : 'bg-white border-gray-300',
|
|
||||||
active ? 'ring-2 ring-offset-2 ring-indigo-500' : '',
|
|
||||||
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center'
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="rounded-full bg-white w-1.5 h-1.5"/>
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="ml-3 flex flex-col">
|
|
||||||
<RadioGroup.Label
|
|
||||||
as="span"
|
|
||||||
className={classNames(checked ? 'text-indigo-900' : 'text-gray-900', 'block text-sm font-medium')}
|
|
||||||
>
|
|
||||||
{setting.label}
|
|
||||||
</RadioGroup.Label>
|
|
||||||
<RadioGroup.Description
|
|
||||||
as="span"
|
|
||||||
className={classNames(checked ? 'text-indigo-700' : 'text-gray-500', 'block text-sm')}
|
|
||||||
>
|
|
||||||
{setting.description}
|
|
||||||
</RadioGroup.Description>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</RadioGroup.Option>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="host"
|
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
|
||||||
>
|
|
||||||
Host
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Field name="host">
|
|
||||||
{({input, meta}) => (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...input}
|
|
||||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
|
||||||
/>
|
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="port"
|
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
|
||||||
>
|
|
||||||
Port
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Field name="port" parse={(v) => v && parseInt(v, 10)}>
|
|
||||||
{({input, meta}) => (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
{...input}
|
|
||||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
|
||||||
/>
|
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
|
|
||||||
<SwitchGroup name="ssl" label="SSL"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="username"
|
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
|
||||||
>
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Field name="username">
|
|
||||||
{({input, meta}) => (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...input}
|
|
||||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
|
||||||
/>
|
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Field name="password">
|
|
||||||
{({input, meta}) => (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...input}
|
|
||||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
|
||||||
/>
|
|
||||||
{meta.touched && meta.error &&
|
|
||||||
<span>{meta.error}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 px-4 border-t border-gray-200 py-5 sm:px-6">
|
|
||||||
<div className="space-x-3 flex justify-between">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
|
|
||||||
onClick={toggleDeleteModal}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
<div className="flex">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames(isSuccessfulTest ? "text-green-500 border-green-500 bg-green-50" : (isErrorTest ? "text-red-500 border-red-500 bg-red-50" : "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:border-rose-700 active:bg-rose-700"), isTesting ? "cursor-not-allowed" : "", "mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150")}
|
|
||||||
disabled={isTesting}
|
|
||||||
onClick={() => testClient(values)}
|
|
||||||
>
|
|
||||||
{isTesting ?
|
|
||||||
<svg
|
|
||||||
className="animate-spin h-5 w-5 text-green-500"
|
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12"
|
|
||||||
r="10" stroke="currentColor"
|
|
||||||
strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
: (isSuccessfulTest ? "OK!" : (isErrorTest ? "ERROR" : "Test"))
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="ml-4 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DEBUG values={values}/>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DownloadClientUpdateForm;
|
|
234
web/src/forms/settings/downloadclient/DownloadClientAddForm.tsx
Normal file
234
web/src/forms/settings/downloadclient/DownloadClientAddForm.tsx
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
import React, { Fragment, useState } from "react";
|
||||||
|
import { useMutation } from "react-query";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_CLIENT_TYPES,
|
||||||
|
DownloadClient,
|
||||||
|
} from "../../../domain/interfaces";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { XIcon } from "@heroicons/react/solid";
|
||||||
|
import { classNames } from "../../../styles/utils";
|
||||||
|
import { Form } from "react-final-form";
|
||||||
|
import DEBUG from "../../../components/debug";
|
||||||
|
import { SwitchGroup, TextFieldWide } from "../../../components/inputs";
|
||||||
|
import { queryClient } from "../../../App";
|
||||||
|
import APIClient from "../../../api/APIClient";
|
||||||
|
import { sleep } from "../../../utils/utils";
|
||||||
|
import { DownloadClientTypeOptions } from "../../../domain/constants";
|
||||||
|
import { RadioFieldsetWide } from "../../../components/inputs/wide";
|
||||||
|
import { componentMap } from "./shared";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<TextFieldWide name="name" label="Name" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<RadioFieldsetWide
|
||||||
|
name="type"
|
||||||
|
legend="Type"
|
||||||
|
options={DownloadClientTypeOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>{componentMap[values.type]}</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;
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { Fragment, useRef, useState } from "react";
|
||||||
|
import { useToggle } from "../../../hooks/hooks";
|
||||||
|
import { useMutation } from "react-query";
|
||||||
|
import { DownloadClient } from "../../../domain/interfaces";
|
||||||
|
import { queryClient } from "../../../App";
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
import { XIcon } from "@heroicons/react/solid";
|
||||||
|
import { classNames } from "../../../styles/utils";
|
||||||
|
import { Form } from "react-final-form";
|
||||||
|
import DEBUG from "../../../components/debug";
|
||||||
|
import { SwitchGroup, TextFieldWide } from "../../../components/inputs";
|
||||||
|
import { DownloadClientTypeOptions } from "../../../domain/constants";
|
||||||
|
import APIClient from "../../../api/APIClient";
|
||||||
|
import { sleep } from "../../../utils/utils";
|
||||||
|
import { componentMap } from "./shared";
|
||||||
|
import { RadioFieldsetWide } from "../../../components/inputs/wide";
|
||||||
|
import { DeleteModal } from "../../../components/modals";
|
||||||
|
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<DeleteModal
|
||||||
|
isOpen={deleteModalIsOpen}
|
||||||
|
toggle={toggleDeleteModal}
|
||||||
|
buttonRef={cancelModalButtonRef}
|
||||||
|
deleteAction={deleteAction}
|
||||||
|
title="Remove download client"
|
||||||
|
text="Are you sure you want to remove this download client? 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: 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,
|
||||||
|
settings: client.settings,
|
||||||
|
}}
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<TextFieldWide name="name" label="Name" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<RadioFieldsetWide
|
||||||
|
name="type"
|
||||||
|
legend="Type"
|
||||||
|
options={DownloadClientTypeOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>{componentMap[values.type]}</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;
|
51
web/src/forms/settings/downloadclient/shared.tsx
Normal file
51
web/src/forms/settings/downloadclient/shared.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
import { SwitchGroup, TextFieldWide } from "../../../components/inputs";
|
||||||
|
import { NumberFieldWide } from "../../../components/inputs/wide";
|
||||||
|
import { useField } from "react-final-form";
|
||||||
|
|
||||||
|
function FormDefaultClientFields() {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<TextFieldWide name="host" label="Host" />
|
||||||
|
|
||||||
|
<NumberFieldWide name="port" label="Port" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<TextFieldWide name="username" label="Username" />
|
||||||
|
<TextFieldWide name="password" label="Password" />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormRadarrFields() {
|
||||||
|
const { input } = useField("settings.basic.auth");
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<TextFieldWide name="host" label="Host" />
|
||||||
|
|
||||||
|
<TextFieldWide name="settings.apikey" label="API key" />
|
||||||
|
|
||||||
|
<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="settings.basic.auth" label="Basic auth" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{input.value === true && (
|
||||||
|
<Fragment>
|
||||||
|
<TextFieldWide name="settings.basic.username" label="Username" />
|
||||||
|
<TextFieldWide name="settings.basic.password" label="Password" />
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export const componentMap: any = {
|
||||||
|
DELUGE_V1: <FormDefaultClientFields />,
|
||||||
|
DELUGE_V2: <FormDefaultClientFields />,
|
||||||
|
QBITTORRENT: <FormDefaultClientFields />,
|
||||||
|
RADARR: <FormRadarrFields />,
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue