Improve Arr integerations
This commit is contained in:
@@ -54,6 +54,7 @@ type Arr struct {
|
||||
SkipRepair bool `json:"skip_repair,omitempty"`
|
||||
DownloadUncached *bool `json:"download_uncached,omitempty"`
|
||||
SelectedDebrid string `json:"selected_debrid,omitempty"`
|
||||
Source string `json:"source,omitempty"` // The source of the arr, e.g. "auto", "config", "". Auto means it was automatically detected from the arr
|
||||
}
|
||||
|
||||
type Repair struct {
|
||||
|
||||
@@ -3,6 +3,7 @@ package arr
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -19,6 +20,13 @@ import (
|
||||
// Type is a type of arr
|
||||
type Type string
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
const (
|
||||
Sonarr Type = "sonarr"
|
||||
Radarr Type = "radarr"
|
||||
@@ -35,10 +43,10 @@ type Arr struct {
|
||||
SkipRepair bool `json:"skip_repair"`
|
||||
DownloadUncached *bool `json:"download_uncached"`
|
||||
SelectedDebrid string `json:"selected_debrid,omitempty"` // The debrid service selected for this arr
|
||||
client *request.Client
|
||||
Source string `json:"source,omitempty"` // The source of the arr, e.g. "auto", "manual". Auto means it was automatically detected from the arr
|
||||
}
|
||||
|
||||
func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *bool, selectedDebrid string) *Arr {
|
||||
func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *bool, selectedDebrid, source string) *Arr {
|
||||
return &Arr{
|
||||
Name: name,
|
||||
Host: host,
|
||||
@@ -47,8 +55,8 @@ func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *b
|
||||
Cleanup: cleanup,
|
||||
SkipRepair: skipRepair,
|
||||
DownloadUncached: downloadUncached,
|
||||
client: request.New(),
|
||||
SelectedDebrid: selectedDebrid,
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,14 +83,11 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", a.Token)
|
||||
if a.client == nil {
|
||||
a.client = request.New()
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
|
||||
for attempts := 0; attempts < 5; attempts++ {
|
||||
resp, err = a.client.Do(req)
|
||||
resp, err = sharedClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,7 +109,7 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
|
||||
|
||||
func (a *Arr) Validate() error {
|
||||
if a.Token == "" || a.Host == "" {
|
||||
return nil
|
||||
return fmt.Errorf("arr not configured: %s", a.Name)
|
||||
}
|
||||
resp, err := a.Request("GET", "/api/v3/health", nil)
|
||||
if err != nil {
|
||||
@@ -146,8 +151,11 @@ func InferType(host, name string) Type {
|
||||
func NewStorage() *Storage {
|
||||
arrs := make(map[string]*Arr)
|
||||
for _, a := range config.Get().Arrs {
|
||||
if a.Host == "" || a.Token == "" || a.Name == "" {
|
||||
continue // Skip if host or token is not set
|
||||
}
|
||||
name := a.Name
|
||||
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid)
|
||||
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
||||
}
|
||||
return &Storage{
|
||||
Arrs: arrs,
|
||||
@@ -158,7 +166,7 @@ func NewStorage() *Storage {
|
||||
func (s *Storage) AddOrUpdate(arr *Arr) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if arr.Name == "" {
|
||||
if arr.Host == "" || arr.Token == "" || arr.Name == "" {
|
||||
return
|
||||
}
|
||||
s.Arrs[arr.Name] = arr
|
||||
@@ -175,19 +183,11 @@ func (s *Storage) GetAll() []*Arr {
|
||||
defer s.mu.Unlock()
|
||||
arrs := make([]*Arr, 0, len(s.Arrs))
|
||||
for _, arr := range s.Arrs {
|
||||
if arr.Host != "" && arr.Token != "" {
|
||||
arrs = append(arrs, arr)
|
||||
}
|
||||
arrs = append(arrs, arr)
|
||||
}
|
||||
return arrs
|
||||
}
|
||||
|
||||
func (s *Storage) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Arrs = make(map[string]*Arr)
|
||||
}
|
||||
|
||||
func (s *Storage) StartSchedule(ctx context.Context) error {
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
@@ -3,10 +3,12 @@ package qbit
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,45 @@ const (
|
||||
arrKey contextKey = "arr"
|
||||
)
|
||||
|
||||
func validateServiceURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
|
||||
// Try parsing as full URL first
|
||||
u, err := url.Parse(urlStr)
|
||||
if err == nil && u.Scheme != "" && u.Host != "" {
|
||||
// It's a full URL, validate scheme
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("URL scheme must be http or https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a host:port format (no scheme)
|
||||
if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") {
|
||||
// Try parsing with http:// prefix
|
||||
testURL := "http://" + urlStr
|
||||
u, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid host:port format: %w", err)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("host is required in host:port format")
|
||||
}
|
||||
|
||||
// Validate port number
|
||||
if u.Port() == "" {
|
||||
return fmt.Errorf("port is required in host:port format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid URL format: %s", urlStr)
|
||||
}
|
||||
|
||||
func getCategory(ctx context.Context) string {
|
||||
if category, ok := ctx.Value(categoryKey).(string); ok {
|
||||
return category
|
||||
@@ -32,7 +73,7 @@ func getHashes(ctx context.Context) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getArr(ctx context.Context) *arr.Arr {
|
||||
func getArrFromContext(ctx context.Context) *arr.Arr {
|
||||
if a, ok := ctx.Value(arrKey).(*arr.Arr); ok {
|
||||
return a
|
||||
}
|
||||
@@ -78,6 +119,10 @@ func (q *QBit) categoryContext(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// authContext creates a middleware that extracts the Arr host and token from the Authorization header
|
||||
// and adds it to the request context.
|
||||
// This is used to identify the Arr instance for the request.
|
||||
// Only a valid host and token will be added to the context/config. The rest are manual
|
||||
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
|
||||
@@ -86,8 +131,9 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
// Check if arr exists
|
||||
a := arrs.Get(category)
|
||||
if a == nil {
|
||||
// Arr is not configured, create a new one
|
||||
downloadUncached := false
|
||||
a = arr.New(category, "", "", false, false, &downloadUncached, "")
|
||||
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
|
||||
}
|
||||
if err == nil {
|
||||
host = strings.TrimSpace(host)
|
||||
@@ -99,7 +145,11 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
a.Token = token
|
||||
}
|
||||
}
|
||||
|
||||
a.Source = "auto"
|
||||
if err := validateServiceURL(a.Host); err != nil {
|
||||
// Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json
|
||||
return
|
||||
}
|
||||
arrs.AddOrUpdate(a)
|
||||
ctx := context.WithValue(r.Context(), arrKey, a)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
||||
@@ -10,14 +10,15 @@ import (
|
||||
|
||||
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_arr := getArr(ctx)
|
||||
_arr := getArrFromContext(ctx)
|
||||
if _arr == nil {
|
||||
// No arr
|
||||
// Arr not in context, return OK
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
return
|
||||
}
|
||||
if err := _arr.Validate(); err != nil {
|
||||
q.logger.Error().Err(err).Msgf("Error validating arr")
|
||||
http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
|
||||
}
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
}
|
||||
@@ -94,9 +95,10 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
debridName := r.FormValue("debrid")
|
||||
category := r.FormValue("category")
|
||||
_arr := getArr(ctx)
|
||||
_arr := getArrFromContext(ctx)
|
||||
if _arr == nil {
|
||||
_arr = arr.New(category, "", "", false, false, nil, "")
|
||||
// Arr is not in context
|
||||
_arr = arr.New(category, "", "", false, false, nil, "", "")
|
||||
}
|
||||
atleastOne := false
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
_arr := _store.Arr().Get(arrName)
|
||||
if _arr == nil {
|
||||
_arr = arr.New(arrName, "", "", false, false, &downloadUncached, "")
|
||||
// These are not found in the config. They are throwaway arrs.
|
||||
_arr = arr.New(arrName, "", "", false, false, &downloadUncached, "", "")
|
||||
}
|
||||
|
||||
// Handle URLs
|
||||
@@ -181,20 +182,38 @@ func (wb *Web) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// Merge config arrs, with arr Storage
|
||||
unique := map[string]config.Arr{}
|
||||
cfg := config.Get()
|
||||
arrCfgs := make([]config.Arr, 0)
|
||||
_store := store.Get()
|
||||
for _, a := range _store.Arr().GetAll() {
|
||||
arrCfgs = append(arrCfgs, config.Arr{
|
||||
Host: a.Host,
|
||||
Name: a.Name,
|
||||
Token: a.Token,
|
||||
Cleanup: a.Cleanup,
|
||||
SkipRepair: a.SkipRepair,
|
||||
DownloadUncached: a.DownloadUncached,
|
||||
})
|
||||
arrStorage := store.Get().Arr()
|
||||
|
||||
// Add existing Arrs from storage
|
||||
for _, a := range arrStorage.GetAll() {
|
||||
if _, ok := unique[a.Name]; !ok {
|
||||
// Only add if not already in the unique map
|
||||
unique[a.Name] = config.Arr{
|
||||
Name: a.Name,
|
||||
Host: a.Host,
|
||||
Token: a.Token,
|
||||
Cleanup: a.Cleanup,
|
||||
SkipRepair: a.SkipRepair,
|
||||
DownloadUncached: a.DownloadUncached,
|
||||
SelectedDebrid: a.SelectedDebrid,
|
||||
Source: a.Source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range cfg.Arrs {
|
||||
if a.Host == "" || a.Token == "" {
|
||||
continue // Skip empty arrs
|
||||
}
|
||||
unique[a.Name] = a
|
||||
}
|
||||
cfg.Arrs = make([]config.Arr, 0, len(unique))
|
||||
for _, a := range unique {
|
||||
cfg.Arrs = append(cfg.Arrs, a)
|
||||
}
|
||||
cfg.Arrs = arrCfgs
|
||||
request.JSONResponse(w, cfg, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -235,27 +254,43 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// Clear legacy single debrid if using array
|
||||
}
|
||||
|
||||
if len(updatedConfig.Arrs) > 0 {
|
||||
currentConfig.Arrs = updatedConfig.Arrs
|
||||
}
|
||||
|
||||
// Update Arrs through the service
|
||||
_store := store.Get()
|
||||
_arr := _store.Arr()
|
||||
_arr.Clear() // Clear existing arrs
|
||||
storage := store.Get()
|
||||
arrStorage := storage.Arr()
|
||||
|
||||
newConfigArrs := make([]config.Arr, 0)
|
||||
for _, a := range updatedConfig.Arrs {
|
||||
_arr.AddOrUpdate(&arr.Arr{
|
||||
Name: a.Name,
|
||||
Host: a.Host,
|
||||
Token: a.Token,
|
||||
Cleanup: a.Cleanup,
|
||||
SkipRepair: a.SkipRepair,
|
||||
DownloadUncached: a.DownloadUncached,
|
||||
SelectedDebrid: a.SelectedDebrid,
|
||||
})
|
||||
if a.Name == "" || a.Host == "" || a.Token == "" {
|
||||
// Skip empty or auto-generated arrs
|
||||
continue
|
||||
}
|
||||
newConfigArrs = append(newConfigArrs, a)
|
||||
}
|
||||
currentConfig.Arrs = updatedConfig.Arrs
|
||||
currentConfig.Arrs = newConfigArrs
|
||||
|
||||
// Add config arr into the config
|
||||
for _, a := range currentConfig.Arrs {
|
||||
if a.Host == "" || a.Token == "" {
|
||||
continue // Skip empty arrs
|
||||
}
|
||||
existingArr := arrStorage.Get(a.Name)
|
||||
if existingArr != nil {
|
||||
// Update existing Arr
|
||||
existingArr.Host = a.Host
|
||||
existingArr.Token = a.Token
|
||||
existingArr.Cleanup = a.Cleanup
|
||||
existingArr.SkipRepair = a.SkipRepair
|
||||
existingArr.DownloadUncached = a.DownloadUncached
|
||||
existingArr.SelectedDebrid = a.SelectedDebrid
|
||||
existingArr.Source = a.Source
|
||||
arrStorage.AddOrUpdate(existingArr)
|
||||
} else {
|
||||
// Create new Arr if it doesn't exist
|
||||
newArr := arr.New(a.Name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
||||
arrStorage.AddOrUpdate(newArr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := currentConfig.Save(); err != nil {
|
||||
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -17,6 +17,37 @@
|
||||
[data-bs-theme="dark"] .nav-pills .nav-link.active {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.config-item.bg-light {
|
||||
background-color: var(--bs-gray-100) !important;
|
||||
border-left: 4px solid var(--bs-info) !important;
|
||||
}
|
||||
|
||||
.config-item input[readonly] {
|
||||
background-color: var(--bs-gray-200);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.config-item select[readonly] {
|
||||
background-color: var(--bs-gray-200);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dark mode specific overrides */
|
||||
[data-bs-theme="dark"] .config-item.bg-light {
|
||||
background-color: var(--bs-gray-800) !important;
|
||||
border-left: 4px solid var(--bs-info) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .config-item input[readonly] {
|
||||
background-color: var(--bs-gray-700);
|
||||
color: var(--bs-gray-300);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .config-item select[readonly] {
|
||||
background-color: var(--bs-gray-700);
|
||||
color: var(--bs-gray-300);
|
||||
}
|
||||
</style>
|
||||
<div class="container mt-4">
|
||||
<form id="configForm">
|
||||
@@ -80,7 +111,8 @@
|
||||
<label>
|
||||
<!-- Empty label to keep the button aligned -->
|
||||
</label>
|
||||
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler();" id="registerMagnetLink">
|
||||
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler();"
|
||||
id="registerMagnetLink">
|
||||
Open Magnet Links in Decypharr
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +135,8 @@
|
||||
id="bindAddress"
|
||||
name="bind_address"
|
||||
placeholder="">
|
||||
<small class="form-text text-muted">Bind address for the application(default is all interface)</small>
|
||||
<small class="form-text text-muted">Bind address for the application(default is all
|
||||
interface)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mt-3">
|
||||
@@ -150,7 +183,8 @@
|
||||
id="minFileSize"
|
||||
name="min_file_size"
|
||||
placeholder="e.g., 10MB, 1GB">
|
||||
<small class="form-text text-muted">Minimum file size to download (Empty for no limit)</small>
|
||||
<small class="form-text text-muted">Minimum file size to download (Empty for no
|
||||
limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mt-3">
|
||||
@@ -161,7 +195,8 @@
|
||||
id="maxFileSize"
|
||||
name="max_file_size"
|
||||
placeholder="e.g., 50GB, 100MB">
|
||||
<small class="form-text text-muted">Maximum file size to download (Empty for no limit)</small>
|
||||
<small class="form-text text-muted">Maximum file size to download (Empty for no
|
||||
limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mt-3">
|
||||
@@ -172,13 +207,15 @@
|
||||
id="removeStalledAfter"
|
||||
name="remove_stalled_after"
|
||||
placeholder="e.g., 1m, 30s, 1h">
|
||||
<small class="form-text text-muted">Remove torrents that have been stalled for this duration</small>
|
||||
<small class="form-text text-muted">Remove torrents that have been stalled for this
|
||||
duration</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-primary next-step" data-next="2">Next <i class="bi bi-arrow-right"></i></button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="2">Next <i
|
||||
class="bi bi-arrow-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +233,8 @@
|
||||
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="1">
|
||||
<i class="bi bi-arrow-left"></i> Previous
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="3">Next <i class="bi bi-arrow-right"></i></button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="3">Next <i
|
||||
class="bi bi-arrow-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,23 +244,31 @@
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="qbit.download_folder">Symlink/Download Folder</label>
|
||||
<input type="text" class="form-control" name="qbit.download_folder" id="qbit.download_folder">
|
||||
<small class="form-text text-muted">Folder where the downloaded files will be stored</small>
|
||||
<input type="text" class="form-control" name="qbit.download_folder"
|
||||
id="qbit.download_folder">
|
||||
<small class="form-text text-muted">Folder where the downloaded files will be
|
||||
stored</small>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="qbit.refresh_interval">Refresh Interval (seconds)</label>
|
||||
<input type="number" class="form-control" name="qbit.refresh_interval" id="qbit.refresh_interval">
|
||||
<input type="number" class="form-control" name="qbit.refresh_interval"
|
||||
id="qbit.refresh_interval">
|
||||
</div>
|
||||
<div class="col-md-5 mb-3">
|
||||
<label class="form-label" for="qbit.max_downloads">Maximum Downloads Limit</label>
|
||||
<input type="number" class="form-control" name="qbit.max_downloads" id="qbit.max_downloads">
|
||||
<small class="form-text text-muted">Maximum number of simultaneous local downloads across all torrents</small>
|
||||
<input type="number" class="form-control" name="qbit.max_downloads"
|
||||
id="qbit.max_downloads">
|
||||
<small class="form-text text-muted">Maximum number of simultaneous local downloads
|
||||
across all torrents</small>
|
||||
</div>
|
||||
<div class="col mb-3">
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="qbit.skip_pre_cache" id="qbit.skip_pre_cache">
|
||||
<label class="form-check-label" for="qbit.skip_pre_cache">Disable Pre-Cache On Download</label>
|
||||
<small class="form-text text-muted">Unchecking this caches a tiny part of your file to speed up import</small>
|
||||
<input type="checkbox" class="form-check-input" name="qbit.skip_pre_cache"
|
||||
id="qbit.skip_pre_cache">
|
||||
<label class="form-check-label" for="qbit.skip_pre_cache">Disable Pre-Cache On
|
||||
Download</label>
|
||||
<small class="form-text text-muted">Unchecking this caches a tiny part of your file
|
||||
to speed up import</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +277,8 @@
|
||||
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="2">
|
||||
<i class="bi bi-arrow-left"></i> Previous
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="4">Next <i class="bi bi-arrow-right"></i></button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="4">Next <i
|
||||
class="bi bi-arrow-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,7 +296,8 @@
|
||||
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="3">
|
||||
<i class="bi bi-arrow-left"></i> Previous
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="5">Next <i class="bi bi-arrow-right"></i></button>
|
||||
<button type="button" class="btn btn-primary next-step" data-next="5">Next <i
|
||||
class="bi bi-arrow-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +307,8 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" class="form-check-input" name="repair.enabled" id="repair.enabled">
|
||||
<input type="checkbox" class="form-check-input" name="repair.enabled"
|
||||
id="repair.enabled">
|
||||
<label class="form-check-label" for="repair.enabled">Enable Scheduled Repair</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,34 +317,43 @@
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label" for="repair.interval">Scheduled Interval</label>
|
||||
<input type="text" class="form-control" name="repair.interval" id="repair.interval" placeholder="e.g., 24h">
|
||||
<small class="form-text text-muted">Interval for the repair process(e.g., 24h, 1d, 03:00, or a crontab)</small>
|
||||
<input type="text" class="form-control" name="repair.interval" id="repair.interval"
|
||||
placeholder="e.g., 24h">
|
||||
<small class="form-text text-muted">Interval for the repair process(e.g., 24h, 1d,
|
||||
03:00, or a crontab)</small>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="repair.workers">Workers</label>
|
||||
<input type="text" class="form-control" name="repair.workers" id="repair.workers">
|
||||
<small class="form-text text-muted">Number of workers to use for the repair process</small>
|
||||
<small class="form-text text-muted">Number of workers to use for the repair
|
||||
process</small>
|
||||
</div>
|
||||
<div class="col-md-5 mb-3">
|
||||
<label class="form-label" for="repair.zurg_url">Zurg URL</label>
|
||||
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url" placeholder="http://zurg:9999">
|
||||
<small class="form-text text-muted">If you have Zurg running, you can use it to speed up the repair process</small>
|
||||
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url"
|
||||
placeholder="http://zurg:9999">
|
||||
<small class="form-text text-muted">If you have Zurg running, you can use it to
|
||||
speed up the repair process</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="repair.use_webdav" id="repair.use_webdav">
|
||||
<input type="checkbox" class="form-check-input" name="repair.use_webdav"
|
||||
id="repair.use_webdav">
|
||||
<label class="form-check-label" for="repair.use_webdav">Use Webdav</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">Use Internal Webdav for repair(make sure webdav is enabled in the debrid section</small>
|
||||
<small class="form-text text-muted">Use Internal Webdav for repair(make sure webdav
|
||||
is enabled in the debrid section</small>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="repair.auto_process" id="repair.auto_process">
|
||||
<input type="checkbox" class="form-check-input" name="repair.auto_process"
|
||||
id="repair.auto_process">
|
||||
<label class="form-check-label" for="repair.auto_process">Auto Process</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">Automatically process the repair job(delete broken symlinks and searches the arr again)</small>
|
||||
<small class="form-text text-muted">Automatically process the repair job(delete
|
||||
broken symlinks and searches the arr again)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,15 +393,44 @@
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
|
||||
<input type="password" class="form-control" name="debrid[${index}].api_key" id="debrid[${index}].api_key" required>
|
||||
<div class="password-toggle-container">
|
||||
<input type="password"
|
||||
class="form-control has-toggle"
|
||||
name="debrid[${index}].api_key"
|
||||
id="debrid[${index}].api_key"
|
||||
required>
|
||||
<button type="button"
|
||||
class="password-toggle-btn"
|
||||
onclick="togglePassword('debrid[${index}].api_key');">
|
||||
<i class="bi bi-eye" id="debrid[${index}].api_key_icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">API Key for the debrid service</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].download_api_keys">Download API Keys</label>
|
||||
<div class="password-toggle-container">
|
||||
<textarea class="form-control has-toggle"
|
||||
name="debrid[${index}].download_api_keys"
|
||||
id="debrid[${index}].download_api_keys"
|
||||
rows="3"
|
||||
style="font-family: monospace; resize: vertical;"
|
||||
placeholder="Enter one API key per line key1 key2 key3"></textarea>
|
||||
<button type="button"
|
||||
class="password-toggle-btn"
|
||||
style="top: 20px;"
|
||||
onclick="togglePasswordTextarea('debrid[${index}].download_api_keys');">
|
||||
<i class="bi bi-eye" id="debrid[${index}].download_api_keys_icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Multiple API keys for download (one per line). If empty, main API key will be used.</small>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].folder">Mount/Rclone Folder</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].folder" id="debrid[${index}].folder" placeholder="e.g. /mnt/remote/realdebrid" required>
|
||||
<small class="form-text text-muted">Path to where you've mounted the debrid files. Usually your rclone path</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].rate_limit" >Rate Limit</label>
|
||||
<input type="text" class="form-control" name="debrid[${index}].rate_limit" id="debrid[${index}].rate_limit" placeholder="e.g., 200/minute" value="250/minute">
|
||||
<small class="form-text text-muted">Rate limit for the debrid service. Confirm your debrid service rate limit</small>
|
||||
@@ -432,7 +519,17 @@
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label" for="debrid[${index}].rc_pass">Rclone RC Password</label>
|
||||
<input type="password" class="form-control webdav-field" name="debrid[${index}].rc_pass" id="debrid[${index}].rc_pass">
|
||||
<div class="password-toggle-container">
|
||||
<input type="password"
|
||||
class="form-control webdav-field has-toggle"
|
||||
name="debrid[${index}].rc_pass"
|
||||
id="debrid[${index}].rc_pass">
|
||||
<button type="button"
|
||||
class="password-toggle-btn"
|
||||
onclick="togglePassword('debrid[${index}].rc_pass');">
|
||||
<i class="bi bi-eye" id="debrid[${index}].rc_pass_icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Rclone RC Password for the webdav server</small>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
@@ -538,7 +635,7 @@
|
||||
const filterTemplate = (debridIndex, dirIndex, filterIndex, filterType) => {
|
||||
let placeholder, label;
|
||||
|
||||
switch(filterType) {
|
||||
switch (filterType) {
|
||||
case 'include':
|
||||
placeholder = "Text that should be included in filename";
|
||||
label = "Include";
|
||||
@@ -632,58 +729,87 @@
|
||||
`;
|
||||
};
|
||||
|
||||
const arrTemplate = (index) => `
|
||||
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-2"
|
||||
onclick="if(confirm('Are you sure you want to delete this arr?')) this.closest('.config-item').remove();"
|
||||
title="Delete this arr">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="arr[${index}].name" id="arr[${index}].name" required>
|
||||
const arrTemplate = (index, data = {}) => `
|
||||
<div class="config-item position-relative mb-3 p-3 border rounded ${data.source === 'auto' ? 'bg-light' : ''}">
|
||||
${data.source !== 'auto' ? `
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-2"
|
||||
onclick="if(confirm('Are you sure you want to delete this arr?')) this.closest('.config-item').remove();"
|
||||
title="Delete this arr">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
` : `
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge bg-info">Auto-detected</span>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].host" class="form-label">Host</label>
|
||||
<input type="text" class="form-control" name="arr[${index}].host" id="arr[${index}].host" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for"arr[${index}].token" class="form-label">API Token</label>
|
||||
<input type="password" class="form-control" name="arr[${index}].token" id="arr[${index}].token" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<select class="form-select" name="arr[${index}].selected_debrid" id="arr[${index}].selected_debrid">
|
||||
<option value="" selected disabled>Select Arr Debrid</option>
|
||||
<option value="realdebrid">Real Debrid</option>
|
||||
<option value="alldebrid">AllDebrid</option>
|
||||
<option value="debrid_link">Debrid Link</option>
|
||||
<option value="torbox">Torbox</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].cleanup" class="form-check-label">Cleanup Queue</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].cleanup" id="arr[${index}].cleanup">
|
||||
`}
|
||||
<div class="row">
|
||||
<input type="hidden" name="arr[${index}].source" value="${data.source || ''}">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].name" class="form-label">Name</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="arr[${index}].name"
|
||||
id="arr[${index}].name"
|
||||
${data.source === 'auto' ? 'readonly' : 'required'}>
|
||||
${data.source === 'auto' ? '<input type="hidden" name="arr[' + index + '].source" value="auto">' : ''}
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].host" class="form-label">Host</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="arr[${index}].host"
|
||||
id="arr[${index}].host"
|
||||
${data.source === 'auto' ? 'readonly' : 'required'}>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].token" class="form-label">API Token</label>
|
||||
<div class="password-toggle-container">
|
||||
<input type="password"
|
||||
class="form-control has-toggle"
|
||||
name="arr[${index}].token"
|
||||
id="arr[${index}].token"
|
||||
${data.source === 'auto' ? 'readonly' : 'required'}>
|
||||
<button type="button"
|
||||
class="password-toggle-btn"
|
||||
onclick="togglePassword('arr[${index}].token');"
|
||||
${data.source === 'auto' ? 'disabled style="opacity: 0.5;"' : ''}>
|
||||
<i class="bi bi-eye" id="arr[${index}].token_icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].skip_repair" class="form-check-label">Skip Repair</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].skip_repair" id="arr[${index}].skip_repair">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="arr[${index}].selected_debrid" class="form-label">Select Arr Debrid</label>
|
||||
<select class="form-select" name="arr[${index}].selected_debrid" id="arr[${index}].selected_debrid">
|
||||
<option value="" selected disabled>Select Arr Debrid</option>
|
||||
<option value="realdebrid">Real Debrid</option>
|
||||
<option value="alldebrid">AllDebrid</option>
|
||||
<option value="debrid_link">Debrid Link</option>
|
||||
<option value="torbox">Torbox</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].download_uncached" class="form-check-label">Download Uncached</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].download_uncached" id="arr[${index}].download_uncached">
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].cleanup" class="form-check-label">Cleanup Queue</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].cleanup" id="arr[${index}].cleanup">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].skip_repair" class="form-check-label">Skip Repair</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].skip_repair" id="arr[${index}].skip_repair">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-3">
|
||||
<div class="form-check">
|
||||
<label for="arr[${index}].download_uncached" class="form-check-label">Download Uncached</label>
|
||||
<input type="checkbox" class="form-check-input" name="arr[${index}].download_uncached" id="arr[${index}].download_uncached">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
|
||||
const debridDirectoryCounts = {};
|
||||
const directoryFilterCounts = {};
|
||||
@@ -739,6 +865,7 @@
|
||||
debridDirectoryCounts[debridIndex]++;
|
||||
return dirIndex;
|
||||
}
|
||||
|
||||
function addFilter(debridIndex, dirIndex, filterType, filterValue = "") {
|
||||
const dirKey = `${debridIndex}-${dirIndex}`;
|
||||
if (!directoryFilterCounts[dirKey]) {
|
||||
@@ -771,7 +898,7 @@
|
||||
}
|
||||
|
||||
// Main functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let debridCount = 0;
|
||||
let arrCount = 0;
|
||||
let currentStep = 1;
|
||||
@@ -785,21 +912,21 @@
|
||||
|
||||
// Step navigation
|
||||
document.querySelectorAll('.nav-link').forEach(navLink => {
|
||||
navLink.addEventListener('click', function() {
|
||||
navLink.addEventListener('click', function () {
|
||||
const stepNumber = parseInt(this.getAttribute('data-step'));
|
||||
goToStep(stepNumber);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.next-step').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
button.addEventListener('click', function () {
|
||||
const nextStep = parseInt(this.getAttribute('data-next'));
|
||||
goToStep(nextStep);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.prev-step').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
button.addEventListener('click', function () {
|
||||
const prevStep = parseInt(this.getAttribute('data-prev'));
|
||||
goToStep(prevStep);
|
||||
});
|
||||
@@ -910,7 +1037,7 @@
|
||||
addArrConfig();
|
||||
});
|
||||
|
||||
$(document).on('change', '.useWebdav', function() {
|
||||
$(document).on('change', '.useWebdav', function () {
|
||||
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
|
||||
if (webdavConfig.length === 0) return;
|
||||
|
||||
@@ -953,7 +1080,7 @@
|
||||
// Save config logic
|
||||
const response = await fetcher('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
@@ -1005,7 +1132,7 @@
|
||||
|
||||
if (data.use_webdav && data.directories) {
|
||||
Object.entries(data.directories).forEach(([dirName, dirData]) => {
|
||||
const dirIndex = addDirectory(debridCount, { name: dirName });
|
||||
const dirIndex = addDirectory(debridCount, {name: dirName});
|
||||
|
||||
// Add filters if available
|
||||
if (dirData.filters) {
|
||||
@@ -1015,6 +1142,20 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.download_api_keys && Array.isArray(data.download_api_keys)) {
|
||||
const downloadKeysTextarea = container.querySelector(`[name="debrid[${debridCount}].download_api_keys"]`);
|
||||
if (downloadKeysTextarea) {
|
||||
downloadKeysTextarea.value = data.download_api_keys.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadKeysTextarea = newDebrid.querySelector(`[name="debrid[${debridCount}].download_api_keys"]`);
|
||||
if (downloadKeysTextarea) {
|
||||
downloadKeysTextarea.style.webkitTextSecurity = 'disc';
|
||||
downloadKeysTextarea.style.textSecurity = 'disc';
|
||||
downloadKeysTextarea.setAttribute('data-password-visible', 'false');
|
||||
}
|
||||
|
||||
debridCount++;
|
||||
@@ -1022,11 +1163,10 @@
|
||||
|
||||
function addArrConfig(data = {}) {
|
||||
const container = document.getElementById('arrConfigs');
|
||||
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
|
||||
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount, data));
|
||||
|
||||
// Add a delete button to the new arr
|
||||
// Don't add delete button for auto-detected arrs since it's already handled in template
|
||||
const newArr = container.lastElementChild;
|
||||
addDeleteButton(newArr, `Delete this arr`);
|
||||
|
||||
if (data) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
@@ -1051,7 +1191,7 @@
|
||||
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
deleteBtn.title = tooltip;
|
||||
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
deleteBtn.addEventListener('click', function () {
|
||||
if (confirm('Are you sure you want to delete this item?')) {
|
||||
element.remove();
|
||||
}
|
||||
@@ -1126,7 +1266,7 @@
|
||||
const nameInput = document.querySelector(`[name="debrid[${i}].directory[${j}].name"]`);
|
||||
if (nameInput && nameInput.value) {
|
||||
const dirName = nameInput.value;
|
||||
debrid.directories[dirName] = { filters: {} };
|
||||
debrid.directories[dirName] = {filters: {}};
|
||||
|
||||
// Get directory key for filter counting
|
||||
const dirKey = `${i}-${j}`;
|
||||
@@ -1146,6 +1286,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
let downloadApiKeysTextarea = document.querySelector(`[name="debrid[${i}].download_api_keys"]`);
|
||||
if (downloadApiKeysTextarea && downloadApiKeysTextarea.value.trim()) {
|
||||
debrid.download_api_keys = downloadApiKeysTextarea.value
|
||||
.split('\n')
|
||||
.map(key => key.trim())
|
||||
.filter(key => key.length > 0);
|
||||
}
|
||||
|
||||
if (debrid.name && debrid.api_key) {
|
||||
config.debrids.push(debrid);
|
||||
}
|
||||
@@ -1163,7 +1311,8 @@
|
||||
cleanup: document.querySelector(`[name="arr[${i}].cleanup"]`).checked,
|
||||
skip_repair: document.querySelector(`[name="arr[${i}].skip_repair"]`).checked,
|
||||
download_uncached: document.querySelector(`[name="arr[${i}].download_uncached"]`).checked,
|
||||
selectedDebrid: document.querySelector(`[name="arr[${i}].selected_debrid"]`).value
|
||||
selected_debrid: document.querySelector(`[name="arr[${i}].selected_debrid"]`).value,
|
||||
source: document.querySelector(`[name="arr[${i}].source"]`).value
|
||||
};
|
||||
|
||||
if (arr.name && arr.host) {
|
||||
|
||||
@@ -121,6 +121,45 @@
|
||||
.theme-toggle:hover {
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
.password-toggle-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-toggle-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.password-toggle-btn:hover {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.form-control.has-toggle {
|
||||
padding-right: 35px;
|
||||
}
|
||||
textarea.has-toggle {
|
||||
-webkit-text-security: disc;
|
||||
text-security: disc;
|
||||
font-family: monospace !important;
|
||||
}
|
||||
|
||||
textarea.has-toggle[data-password-visible="true"] {
|
||||
-webkit-text-security: none;
|
||||
text-security: none;
|
||||
}
|
||||
|
||||
/* Adjust toggle button position for textareas */
|
||||
.password-toggle-container textarea.has-toggle ~ .password-toggle-btn {
|
||||
top: 20px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
@@ -297,6 +336,57 @@
|
||||
});
|
||||
};
|
||||
|
||||
function createPasswordField(name, id, placeholder = "", required = false) {
|
||||
return `
|
||||
<div class="password-toggle-container">
|
||||
<input type="password"
|
||||
class="form-control has-toggle"
|
||||
name="${name}"
|
||||
id="${id}"
|
||||
placeholder="${placeholder}"
|
||||
${required ? 'required' : ''}>
|
||||
<button type="button"
|
||||
class="password-toggle-btn"
|
||||
onclick="togglePassword('${id}');">
|
||||
<i class="bi bi-eye" id="${id}_icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function togglePassword(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const icon = document.getElementById(fieldId + '_icon');
|
||||
|
||||
if (field.type === 'password') {
|
||||
field.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
field.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
|
||||
// Add this function to handle textarea password toggling
|
||||
function togglePasswordTextarea(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const icon = document.getElementById(fieldId + '_icon');
|
||||
|
||||
if (field.style.webkitTextSecurity === 'disc' || field.style.webkitTextSecurity === '') {
|
||||
// Show text
|
||||
field.style.webkitTextSecurity = 'none';
|
||||
field.style.textSecurity = 'none'; // For other browsers
|
||||
field.setAttribute('data-password-visible', 'true');
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
// Hide text
|
||||
field.style.webkitTextSecurity = 'disc';
|
||||
field.style.textSecurity = 'disc'; // For other browsers
|
||||
field.setAttribute('data-password-visible', 'false');
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
|
||||
// Theme management
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const lightIcon = document.getElementById('lightIcon');
|
||||
|
||||
Reference in New Issue
Block a user