Testing a new UI
@@ -7,14 +7,14 @@ tmp_dir = "tmp"
|
|||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/decypharr/pkg/version.Version=0.0.0 -X github.com/sirrobot01/decypharr/pkg/version.Channel=dev\" -o ./tmp/main .'"
|
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/decypharr/pkg/version.Version=0.0.0 -X github.com/sirrobot01/decypharr/pkg/version.Channel=dev\" -o ./tmp/main .'"
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"]
|
exclude_dir = ["tmp", "vendor", "testdata", "data"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
exclude_regex = ["_test.go"]
|
exclude_regex = ["_test.go"]
|
||||||
exclude_unchanged = false
|
exclude_unchanged = false
|
||||||
follow_symlink = false
|
follow_symlink = false
|
||||||
full_bin = ""
|
full_bin = ""
|
||||||
include_dir = []
|
include_dir = []
|
||||||
include_ext = ["go", "tpl", "tmpl", "html", ".json"]
|
include_ext = ["go", "tpl", "tmpl", "html", ".json", ".js", ".css", ".scss", ".ts", ".tsx", ".vue"]
|
||||||
include_file = []
|
include_file = []
|
||||||
kill_delay = "0s"
|
kill_delay = "0s"
|
||||||
log = "build-errors.log"
|
log = "build-errors.log"
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/sirrobot01/decypharr/internal/logger"
|
"github.com/sirrobot01/decypharr/internal/logger"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,10 +29,6 @@ func New(handlers map[string]http.Handler) *Server {
|
|||||||
s := &Server{
|
s := &Server{
|
||||||
logger: l,
|
logger: l,
|
||||||
}
|
}
|
||||||
staticPath, _ := url.JoinPath(cfg.URLBase, "static")
|
|
||||||
r.Handle(staticPath+"/*",
|
|
||||||
http.StripPrefix(staticPath, http.FileServer(http.Dir("static"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
r.Route(cfg.URLBase, func(r chi.Router) {
|
r.Route(cfg.URLBase, func(r chi.Router) {
|
||||||
for pattern, handler := range handlers {
|
for pattern, handler := range handlers {
|
||||||
|
|||||||
672
pkg/web/assets/css/styles.css
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
/* Custom Styles for Decypharr */
|
||||||
|
|
||||||
|
/* Smooth transitions for all interactive elements */
|
||||||
|
* {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Context menu styles */
|
||||||
|
.context-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid hsl(var(--bc) / 0.2);
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0.95) translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu:not(.hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
animation: contextMenuAppear 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuAppear {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth progress bar animations */
|
||||||
|
.progress {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::-webkit-progress-value {
|
||||||
|
transition: width 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress::-moz-progress-bar {
|
||||||
|
transition: width 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced button animations */
|
||||||
|
.btn {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: all 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effects */
|
||||||
|
.card {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table row animations */
|
||||||
|
.table tbody tr {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: hsl(var(--b2));
|
||||||
|
transform: scale(1.005);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item selection styles */
|
||||||
|
.item-row.selected {
|
||||||
|
background-color: hsl(var(--p) / 0.1) !important;
|
||||||
|
border-left: 4px solid hsl(var(--p));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stepper navigation */
|
||||||
|
.stepper-nav .nav-link {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-nav .nav-link::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-nav .nav-link:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal animations */
|
||||||
|
.modal {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast animations */
|
||||||
|
.toast-container .alert {
|
||||||
|
animation: toastSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner improvements */
|
||||||
|
.loading {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge animations */
|
||||||
|
.badge {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form focus improvements */
|
||||||
|
.form-control input:focus,
|
||||||
|
.form-control textarea:focus,
|
||||||
|
.form-control select:focus {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--p) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination smooth transitions */
|
||||||
|
.join .btn {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join .btn:not(.btn-active):hover {
|
||||||
|
background-color: hsl(var(--b3));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Toggle */
|
||||||
|
/* Password toggle styles */
|
||||||
|
.password-toggle-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
color: hsl(var(--bc) / 0.6);
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-top-right-radius: var(--rounded-btn, 0.5rem);
|
||||||
|
border-bottom-right-radius: var(--rounded-btn, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-btn:hover {
|
||||||
|
background-color: hsl(var(--bc) / 0.1);
|
||||||
|
color: hsl(var(--bc) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input password fields - make room for the button */
|
||||||
|
.input.input-has-toggle {
|
||||||
|
padding-right: 40px !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea password fields */
|
||||||
|
.textarea.has-toggle {
|
||||||
|
padding-right: 40px !important;
|
||||||
|
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace !important;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific positioning for textarea toggles */
|
||||||
|
.password-toggle-btn.textarea-toggle {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: auto;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 0;
|
||||||
|
border-top-right-radius: var(--rounded-btn, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better masking for textareas */
|
||||||
|
.textarea.has-toggle[data-password-visible="false"],
|
||||||
|
.textarea.has-toggle:not([data-password-visible="true"]) {
|
||||||
|
-webkit-text-security: disc;
|
||||||
|
text-security: disc;
|
||||||
|
font-family: monospace !important;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea.has-toggle[data-password-visible="true"] {
|
||||||
|
-webkit-text-security: none;
|
||||||
|
text-security: none;
|
||||||
|
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace !important;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input password field styling */
|
||||||
|
.input[type="password"] {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input[type="text"].was-password {
|
||||||
|
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode button styling */
|
||||||
|
[data-theme="dark"] .password-toggle-btn {
|
||||||
|
color: hsl(var(--bc) / 0.6);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .password-toggle-btn:hover {
|
||||||
|
background-color: hsl(var(--bc) / 0.15);
|
||||||
|
color: hsl(var(--bc) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode button styling */
|
||||||
|
[data-theme="light"] .password-toggle-btn {
|
||||||
|
color: hsl(var(--bc) / 0.7);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .password-toggle-btn:hover {
|
||||||
|
background-color: hsl(var(--bc) / 0.1);
|
||||||
|
color: hsl(var(--bc) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon sizing */
|
||||||
|
.password-toggle-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure the container fills the width */
|
||||||
|
.password-toggle-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle-container .input,
|
||||||
|
.password-toggle-container .textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer animations */
|
||||||
|
.drawer-side {
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown animations */
|
||||||
|
.dropdown-content {
|
||||||
|
animation: dropdownSlideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced filter UI */
|
||||||
|
.filters-container .filter-item {
|
||||||
|
animation: filterItemAppear 0.2s ease-out;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes filterItemAppear {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item:hover {
|
||||||
|
background-color: hsl(var(--b2));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
margin: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific improvements */
|
||||||
|
[data-theme="dark"] .glass {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix input text colors in dark mode */
|
||||||
|
[data-theme="dark"] .input,
|
||||||
|
[data-theme="dark"] .textarea,
|
||||||
|
[data-theme="dark"] .select {
|
||||||
|
color: #ffffff !important; /* Force white text for values */
|
||||||
|
background-color: hsl(var(--b1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep placeholders dim and distinguishable */
|
||||||
|
[data-theme="dark"] .input::placeholder,
|
||||||
|
[data-theme="dark"] .textarea::placeholder {
|
||||||
|
color: #6b7280 !important; /* Gray-500 equivalent */
|
||||||
|
opacity: 1 !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states */
|
||||||
|
[data-theme="dark"] .input:focus,
|
||||||
|
[data-theme="dark"] .textarea:focus,
|
||||||
|
[data-theme="dark"] .select:focus {
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: hsl(var(--b1));
|
||||||
|
border-color: hsl(var(--p));
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--p) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select options */
|
||||||
|
[data-theme="dark"] .select option {
|
||||||
|
background-color: hsl(var(--b1));
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled inputs */
|
||||||
|
[data-theme="dark"] .input:disabled,
|
||||||
|
[data-theme="dark"] .textarea:disabled,
|
||||||
|
[data-theme="dark"] .select:disabled {
|
||||||
|
color: #9ca3af !important; /* Gray-400 */
|
||||||
|
background-color: hsl(var(--b2));
|
||||||
|
border-color: hsl(var(--bc) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .input:disabled::placeholder,
|
||||||
|
[data-theme="dark"] .textarea:disabled::placeholder {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Readonly inputs (like auto-detected configs) */
|
||||||
|
[data-theme="dark"] .input[readonly],
|
||||||
|
[data-theme="dark"] .textarea[readonly],
|
||||||
|
[data-theme="dark"] .select[readonly] {
|
||||||
|
color: #d1d5db !important; /* Gray-300 */
|
||||||
|
background-color: hsl(var(--b2));
|
||||||
|
border-color: hsl(var(--bc) / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password fields with monospace */
|
||||||
|
[data-theme="dark"] .password-toggle-container .input {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .password-toggle-container .textarea {
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form labels */
|
||||||
|
[data-theme="dark"] .label-text {
|
||||||
|
color: #f3f4f6; /* Gray-100 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .label-text-alt {
|
||||||
|
color: #9ca3af; /* Gray-400 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File input */
|
||||||
|
[data-theme="dark"] .file-input {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-input::file-selector-button {
|
||||||
|
background-color: hsl(var(--b3));
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid hsl(var(--bc) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search inputs */
|
||||||
|
[data-theme="dark"] input[type="search"] {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] input[type="search"]::placeholder {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number inputs */
|
||||||
|
[data-theme="dark"] input[type="number"] {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL inputs */
|
||||||
|
[data-theme="dark"] input[type="url"] {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Email inputs */
|
||||||
|
[data-theme="dark"] input[type="email"] {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date/time inputs */
|
||||||
|
[data-theme="dark"] input[type="date"],
|
||||||
|
[data-theme="dark"] input[type="time"],
|
||||||
|
[data-theme="dark"] input[type="datetime-local"] {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix autofill in dark mode */
|
||||||
|
[data-theme="dark"] .input:-webkit-autofill,
|
||||||
|
[data-theme="dark"] .input:-webkit-autofill:hover,
|
||||||
|
[data-theme="dark"] .input:-webkit-autofill:focus {
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px hsl(var(--b1)) inset !important;
|
||||||
|
-webkit-text-fill-color: #ffffff !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error states */
|
||||||
|
[data-theme="dark"] .input-error {
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: hsl(var(--er));
|
||||||
|
background-color: hsl(var(--er) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .input-error::placeholder {
|
||||||
|
color: #ef4444 !important; /* Red placeholder for errors */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive improvements */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--b1));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--bc) / 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--bc) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
/* Tab Navigation Styles */
|
||||||
|
.tab-button {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: hsl(var(--p)) !important;
|
||||||
|
border-color: hsl(var(--p)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:not(.active) {
|
||||||
|
color: hsl(var(--bc) / 0.7);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:not(.active):hover {
|
||||||
|
color: hsl(var(--bc));
|
||||||
|
border-color: hsl(var(--bc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab content styling */
|
||||||
|
.tab-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content:not(.hidden) {
|
||||||
|
display: block;
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tab-button {
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav[aria-label="Configuration Tabs"] {
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav[aria-label="Configuration Tabs"]::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav[aria-label="Configuration Tabs"]::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--b2));
|
||||||
|
}
|
||||||
|
|
||||||
|
nav[aria-label="Configuration Tabs"]::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--bc) / 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific styling */
|
||||||
|
[data-theme="dark"] .tab-button:not(.active) {
|
||||||
|
color: hsl(var(--bc) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .tab-button:not(.active):hover {
|
||||||
|
color: hsl(var(--bc) / 0.9);
|
||||||
|
border-color: hsl(var(--bc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode specific styling */
|
||||||
|
[data-theme="light"] .tab-button:not(.active) {
|
||||||
|
color: hsl(var(--bc) / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .tab-button:not(.active):hover {
|
||||||
|
color: hsl(var(--bc) / 0.9);
|
||||||
|
border-color: hsl(var(--bc) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced border styling */
|
||||||
|
.tab-button {
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure the nav container has proper spacing */
|
||||||
|
nav[aria-label="Configuration Tabs"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
min-height: 60px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon and text alignment */
|
||||||
|
.tab-button .flex {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states for accessibility */
|
||||||
|
.tab-button:focus {
|
||||||
|
outline: 2px solid hsl(var(--p));
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
BIN
pkg/web/assets/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
pkg/web/assets/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
pkg/web/assets/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
pkg/web/assets/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 665 B |
BIN
pkg/web/assets/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
pkg/web/assets/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
pkg/web/assets/favicon/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
638
pkg/web/assets/js/common.js
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
// Common utilities and functions
|
||||||
|
class DecypharrUtils {
|
||||||
|
constructor() {
|
||||||
|
this.urlBase = window.urlBase || '';
|
||||||
|
this.toastContainer = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupToastSystem();
|
||||||
|
this.setupThemeToggle();
|
||||||
|
this.setupPasswordToggles();
|
||||||
|
this.setupVersionInfo();
|
||||||
|
this.setupGlobalEventListeners();
|
||||||
|
this.createToastContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create toast container if it doesn't exist
|
||||||
|
createToastContainer() {
|
||||||
|
let container = document.querySelector('.toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'toast-container fixed bottom-4 right-4 z-50 space-y-2';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
this.toastContainer = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup toast system
|
||||||
|
setupToastSystem() {
|
||||||
|
// Add toast CSS styles
|
||||||
|
this.addToastStyles();
|
||||||
|
|
||||||
|
// Global toast handler
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
console.error('Global error:', e.error);
|
||||||
|
this.createToast(`Unexpected error: ${e.error?.message || 'Unknown error'}`, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
console.error('Unhandled promise rejection:', e.reason);
|
||||||
|
this.createToast(`Promise rejected: ${e.reason?.message || 'Unknown error'}`, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add toast styles to document
|
||||||
|
addToastStyles() {
|
||||||
|
if (document.getElementById('toast-styles')) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'toast-styles';
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes toastSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastSlideOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container .alert {
|
||||||
|
animation: toastSlideIn 0.3s ease-out;
|
||||||
|
max-width: 400px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container .alert.toast-closing {
|
||||||
|
animation: toastSlideOut 0.3s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.toast-container {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container .alert {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL joining utility
|
||||||
|
joinURL(base, path) {
|
||||||
|
if (!base.endsWith('/')) base += '/';
|
||||||
|
if (path.startsWith('/')) path = path.substring(1);
|
||||||
|
return base + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced fetch wrapper
|
||||||
|
async fetcher(endpoint, options = {}) {
|
||||||
|
const url = this.joinURL(this.urlBase, endpoint);
|
||||||
|
|
||||||
|
// Handle FormData - don't set Content-Type for FormData
|
||||||
|
const defaultOptions = {
|
||||||
|
headers: {},
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set Content-Type if not FormData
|
||||||
|
if (!(options.body instanceof FormData)) {
|
||||||
|
defaultOptions.headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge headers
|
||||||
|
defaultOptions.headers = {
|
||||||
|
...defaultOptions.headers,
|
||||||
|
...options.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, defaultOptions);
|
||||||
|
|
||||||
|
// Add loading state management
|
||||||
|
if (options.loadingButton) {
|
||||||
|
this.setButtonLoading(options.loadingButton, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (options.loadingButton) {
|
||||||
|
this.setButtonLoading(options.loadingButton, false);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced toast system
|
||||||
|
createToast(message, type = 'success', duration = null) {
|
||||||
|
const toastTimeouts = {
|
||||||
|
success: 5000,
|
||||||
|
warning: 10000,
|
||||||
|
error: 15000,
|
||||||
|
info: 7000
|
||||||
|
};
|
||||||
|
|
||||||
|
type = ['success', 'warning', 'error', 'info'].includes(type) ? type : 'success';
|
||||||
|
duration = duration || toastTimeouts[type];
|
||||||
|
|
||||||
|
// Ensure toast container exists
|
||||||
|
if (!this.toastContainer) {
|
||||||
|
this.createToastContainer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastId = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const alertTypeClass = {
|
||||||
|
success: 'alert-success',
|
||||||
|
warning: 'alert-warning',
|
||||||
|
error: 'alert-error',
|
||||||
|
info: 'alert-info'
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>',
|
||||||
|
error: '<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>',
|
||||||
|
warning: '<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>',
|
||||||
|
info: '<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>'
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastHtml = `
|
||||||
|
<div id="${toastId}" class="alert ${alertTypeClass[type]} shadow-lg mb-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-6 h-6 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
${icons[type]}
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-sm">${message.replace(/\n/g, '<br>')}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-ghost btn-circle" onclick="window.decypharrUtils.closeToast('${toastId}');">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||||
|
|
||||||
|
// Auto-close toast
|
||||||
|
const timeoutId = setTimeout(() => this.closeToast(toastId), duration);
|
||||||
|
|
||||||
|
// Store timeout ID for manual closing
|
||||||
|
const toastElement = document.getElementById(toastId);
|
||||||
|
if (toastElement) {
|
||||||
|
toastElement.dataset.timeoutId = timeoutId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toastId;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeToast(toastId) {
|
||||||
|
const toastElement = document.getElementById(toastId);
|
||||||
|
if (toastElement) {
|
||||||
|
// Clear auto-close timeout
|
||||||
|
if (toastElement.dataset.timeoutId) {
|
||||||
|
clearTimeout(parseInt(toastElement.dataset.timeoutId));
|
||||||
|
}
|
||||||
|
|
||||||
|
toastElement.classList.add('toast-closing');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toastElement.parentNode) {
|
||||||
|
toastElement.remove();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all toasts
|
||||||
|
closeAllToasts() {
|
||||||
|
const toasts = this.toastContainer?.querySelectorAll('.alert');
|
||||||
|
if (toasts) {
|
||||||
|
toasts.forEach(toast => {
|
||||||
|
if (toast.id) {
|
||||||
|
this.closeToast(toast.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button loading state management
|
||||||
|
setButtonLoading(buttonElement, loading = true, originalText = null) {
|
||||||
|
if (typeof buttonElement === 'string') {
|
||||||
|
buttonElement = document.getElementById(buttonElement) || document.querySelector(buttonElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buttonElement) return;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
buttonElement.disabled = true;
|
||||||
|
if (!buttonElement.dataset.originalText) {
|
||||||
|
buttonElement.dataset.originalText = originalText || buttonElement.innerHTML;
|
||||||
|
}
|
||||||
|
buttonElement.innerHTML = '<span class="loading loading-spinner loading-sm"></span>Processing...';
|
||||||
|
buttonElement.classList.add('loading-state');
|
||||||
|
} else {
|
||||||
|
buttonElement.disabled = false;
|
||||||
|
buttonElement.innerHTML = buttonElement.dataset.originalText || 'Submit';
|
||||||
|
buttonElement.classList.remove('loading-state');
|
||||||
|
delete buttonElement.dataset.originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password field utilities
|
||||||
|
setupPasswordToggles() {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const toggleBtn = e.target.closest('.password-toggle-btn');
|
||||||
|
if (toggleBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Find the associated input field
|
||||||
|
const container = toggleBtn.closest('.password-toggle-container');
|
||||||
|
if (container) {
|
||||||
|
const input = container.querySelector('input, textarea');
|
||||||
|
const icon = toggleBtn.querySelector('i');
|
||||||
|
if (input && icon) {
|
||||||
|
this.togglePasswordField(input, icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePasswordField(field, icon) {
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
if (field.tagName.toLowerCase() === 'textarea') {
|
||||||
|
this.togglePasswordTextarea(field, icon);
|
||||||
|
} else {
|
||||||
|
this.togglePasswordInput(field, icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePasswordInput(field, icon) {
|
||||||
|
if (field.type === 'password') {
|
||||||
|
field.type = 'text';
|
||||||
|
icon.className = 'bi bi-eye-slash';
|
||||||
|
} else {
|
||||||
|
field.type = 'password';
|
||||||
|
icon.className = 'bi bi-eye';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePasswordTextarea(field, icon) {
|
||||||
|
const isHidden = field.style.webkitTextSecurity === 'disc' ||
|
||||||
|
field.style.webkitTextSecurity === '' ||
|
||||||
|
field.getAttribute('data-password-visible') !== 'true';
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
field.style.webkitTextSecurity = 'none';
|
||||||
|
field.style.textSecurity = 'none';
|
||||||
|
field.setAttribute('data-password-visible', 'true');
|
||||||
|
icon.className = 'bi bi-eye-slash';
|
||||||
|
} else {
|
||||||
|
field.style.webkitTextSecurity = 'disc';
|
||||||
|
field.style.textSecurity = 'disc';
|
||||||
|
field.setAttribute('data-password-visible', 'false');
|
||||||
|
icon.className = 'bi bi-eye';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy methods for backward compatibility
|
||||||
|
togglePassword(fieldId) {
|
||||||
|
const field = document.getElementById(fieldId);
|
||||||
|
const button = field?.closest('.password-toggle-container')?.querySelector('.password-toggle-btn');
|
||||||
|
let icon = button.querySelector("i");
|
||||||
|
if (field && icon) {
|
||||||
|
this.togglePasswordField(field, icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme management
|
||||||
|
setupThemeToggle() {
|
||||||
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
|
||||||
|
if (!themeToggle) return;
|
||||||
|
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
htmlElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
themeToggle.checked = theme === 'dark';
|
||||||
|
|
||||||
|
// Smooth theme transition
|
||||||
|
document.body.style.transition = 'background-color 0.3s ease, color 0.3s ease';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.style.transition = '';
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
// Emit theme change event
|
||||||
|
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load saved theme
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
setTheme(savedTheme);
|
||||||
|
} else if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
|
||||||
|
setTheme('dark');
|
||||||
|
} else {
|
||||||
|
setTheme('light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle event
|
||||||
|
themeToggle.addEventListener('change', () => {
|
||||||
|
const currentTheme = htmlElement.getAttribute('data-theme');
|
||||||
|
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||||
|
if (!localStorage.getItem('theme')) {
|
||||||
|
setTheme(e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version info
|
||||||
|
async setupVersionInfo() {
|
||||||
|
try {
|
||||||
|
const response = await this.fetcher('/version');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch version');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const versionBadge = document.getElementById('version-badge');
|
||||||
|
|
||||||
|
if (versionBadge) {
|
||||||
|
versionBadge.innerHTML = `
|
||||||
|
<a href="https://github.com/sirrobot01/decypharr/releases/tag/${data.version}"
|
||||||
|
target="_blank"
|
||||||
|
class="text-current hover:text-primary transition-colors">
|
||||||
|
${data.channel}-${data.version}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Remove existing badge classes
|
||||||
|
versionBadge.classList.remove('badge-warning', 'badge-error', 'badge-ghost');
|
||||||
|
|
||||||
|
if (data.channel === 'beta') {
|
||||||
|
versionBadge.classList.add('badge-warning');
|
||||||
|
} else if (data.channel === 'nightly') {
|
||||||
|
versionBadge.classList.add('badge-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching version:', error);
|
||||||
|
const versionBadge = document.getElementById('version-badge');
|
||||||
|
if (versionBadge) {
|
||||||
|
versionBadge.textContent = 'Unknown';
|
||||||
|
versionBadge.classList.add('badge-ghost');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global event listeners
|
||||||
|
setupGlobalEventListeners() {
|
||||||
|
// Smooth scroll for anchor links
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const link = e.target.closest('a[href^="#"]');
|
||||||
|
if (link && link.getAttribute('href') !== '#') {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.querySelector(link.getAttribute('href'));
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhanced form validation
|
||||||
|
document.addEventListener('invalid', (e) => {
|
||||||
|
e.target.classList.add('input-error');
|
||||||
|
setTimeout(() => e.target.classList.remove('input-error'), 3000);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Escape key closes modals and dropdowns
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// Close modals
|
||||||
|
document.querySelectorAll('.modal[open]').forEach(modal => modal.close());
|
||||||
|
|
||||||
|
// Close dropdowns
|
||||||
|
document.querySelectorAll('.dropdown-open').forEach(dropdown => {
|
||||||
|
dropdown.classList.remove('dropdown-open');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close context menus
|
||||||
|
document.querySelectorAll('.context-menu:not(.hidden)').forEach(menu => {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + / for help (if help system exists)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.showKeyboardShortcuts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page visibility changes
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
// Page is hidden - pause auto-refresh timers if any
|
||||||
|
window.dispatchEvent(new CustomEvent('pageHidden'));
|
||||||
|
} else {
|
||||||
|
// Page is visible - resume auto-refresh timers if any
|
||||||
|
window.dispatchEvent(new CustomEvent('pageVisible'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle online/offline status
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
this.createToast('Connection restored', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
this.createToast('Connection lost - working offline', 'warning');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show keyboard shortcuts modal
|
||||||
|
showKeyboardShortcuts() {
|
||||||
|
const shortcuts = [
|
||||||
|
{ key: 'Esc', description: 'Close modals and dropdowns' },
|
||||||
|
{ key: 'Ctrl + /', description: 'Show this help' },
|
||||||
|
{ key: 'Ctrl + R', description: 'Refresh page' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const modal = document.createElement('dialog');
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-box">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||||
|
</form>
|
||||||
|
<h3 class="font-bold text-lg mb-4">Keyboard Shortcuts</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${shortcuts.map(shortcut => `
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="kbd kbd-sm">${shortcut.key}</span>
|
||||||
|
<span class="text-sm">${shortcut.description}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
modal.showModal();
|
||||||
|
|
||||||
|
modal.addEventListener('close', () => {
|
||||||
|
document.body.removeChild(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility methods
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatSpeed(speed) {
|
||||||
|
return `${this.formatBytes(speed)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDuration(seconds) {
|
||||||
|
if (!seconds || seconds === 0) return '0s';
|
||||||
|
|
||||||
|
const units = [
|
||||||
|
{ label: 'd', seconds: 86400 },
|
||||||
|
{ label: 'h', seconds: 3600 },
|
||||||
|
{ label: 'm', seconds: 60 },
|
||||||
|
{ label: 's', seconds: 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let remaining = seconds;
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const count = Math.floor(remaining / unit.seconds);
|
||||||
|
if (count > 0) {
|
||||||
|
parts.push(`${count}${unit.label}`);
|
||||||
|
remaining %= unit.seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.slice(0, 2).join(' ') || '0s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce function
|
||||||
|
debounce(func, wait, immediate = false) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
if (!immediate) func(...args);
|
||||||
|
};
|
||||||
|
const callNow = immediate && !timeout;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
if (callNow) func(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle function
|
||||||
|
throttle(func, limit) {
|
||||||
|
let inThrottle;
|
||||||
|
return function(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(this, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard utility
|
||||||
|
async copyToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
this.createToast('Copied to clipboard', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
this.createToast('Failed to copy to clipboard', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
isValidUrl(string) {
|
||||||
|
try {
|
||||||
|
new URL(string);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text ? text.replace(/[&<>"']/g, (m) => map[m]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current theme
|
||||||
|
getCurrentTheme() {
|
||||||
|
return document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network status
|
||||||
|
isOnline() {
|
||||||
|
return navigator.onLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize utilities
|
||||||
|
window.decypharrUtils = new DecypharrUtils();
|
||||||
|
|
||||||
|
// Global functions for backward compatibility
|
||||||
|
window.fetcher = (endpoint, options = {}) => window.decypharrUtils.fetcher(endpoint, options);
|
||||||
|
window.createToast = (message, type, duration) => window.decypharrUtils.createToast(message, type, duration);
|
||||||
|
|
||||||
|
// Export for ES6 modules if needed
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = DecypharrUtils;
|
||||||
|
}
|
||||||
1214
pkg/web/assets/js/config.js
Normal file
553
pkg/web/assets/js/dashboard.js
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
// Dashboard functionality for torrent management
|
||||||
|
class TorrentDashboard {
|
||||||
|
constructor() {
|
||||||
|
this.state = {
|
||||||
|
torrents: [],
|
||||||
|
selectedTorrents: new Set(),
|
||||||
|
categories: new Set(),
|
||||||
|
filteredTorrents: [],
|
||||||
|
selectedCategory: '',
|
||||||
|
selectedState: '',
|
||||||
|
sortBy: 'added_on',
|
||||||
|
itemsPerPage: 20,
|
||||||
|
currentPage: 1,
|
||||||
|
selectedTorrentContextMenu: null
|
||||||
|
};
|
||||||
|
|
||||||
|
this.refs = {
|
||||||
|
torrentsList: document.getElementById('torrentsList'),
|
||||||
|
categoryFilter: document.getElementById('categoryFilter'),
|
||||||
|
stateFilter: document.getElementById('stateFilter'),
|
||||||
|
sortSelector: document.getElementById('sortSelector'),
|
||||||
|
selectAll: document.getElementById('selectAll'),
|
||||||
|
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
|
||||||
|
refreshBtn: document.getElementById('refreshBtn'),
|
||||||
|
torrentContextMenu: document.getElementById('torrentContextMenu'),
|
||||||
|
paginationControls: document.getElementById('paginationControls'),
|
||||||
|
paginationInfo: document.getElementById('paginationInfo'),
|
||||||
|
emptyState: document.getElementById('emptyState')
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.bindEvents();
|
||||||
|
this.loadTorrents();
|
||||||
|
this.startAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Refresh button
|
||||||
|
this.refs.refreshBtn.addEventListener('click', () => this.loadTorrents());
|
||||||
|
|
||||||
|
// Batch delete
|
||||||
|
this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedTorrents());
|
||||||
|
|
||||||
|
// Select all checkbox
|
||||||
|
this.refs.selectAll.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked));
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
this.refs.categoryFilter.addEventListener('change', (e) => this.setFilter('category', e.target.value));
|
||||||
|
this.refs.stateFilter.addEventListener('change', (e) => this.setFilter('state', e.target.value));
|
||||||
|
this.refs.sortSelector.addEventListener('change', (e) => this.setSort(e.target.value));
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
this.bindContextMenu();
|
||||||
|
|
||||||
|
// Torrent selection
|
||||||
|
this.refs.torrentsList.addEventListener('change', (e) => {
|
||||||
|
if (e.target.classList.contains('torrent-select')) {
|
||||||
|
this.toggleTorrentSelection(e.target.dataset.hash, e.target.checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindContextMenu() {
|
||||||
|
// Show context menu
|
||||||
|
this.refs.torrentsList.addEventListener('contextmenu', (e) => {
|
||||||
|
const row = e.target.closest('tr[data-hash]');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
this.showContextMenu(e, row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide context menu
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.refs.torrentContextMenu.contains(e.target)) {
|
||||||
|
this.hideContextMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Context menu actions
|
||||||
|
this.refs.torrentContextMenu.addEventListener('click', (e) => {
|
||||||
|
const action = e.target.closest('[data-action]')?.dataset.action;
|
||||||
|
if (action) {
|
||||||
|
this.handleContextAction(action);
|
||||||
|
this.hideContextMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showContextMenu(event, row) {
|
||||||
|
this.state.selectedTorrentContextMenu = {
|
||||||
|
hash: row.dataset.hash,
|
||||||
|
name: row.dataset.name,
|
||||||
|
category: row.dataset.category || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
this.refs.torrentContextMenu.querySelector('.torrent-name').textContent =
|
||||||
|
this.state.selectedTorrentContextMenu.name;
|
||||||
|
|
||||||
|
const { pageX, pageY } = event;
|
||||||
|
const { clientWidth, clientHeight } = document.documentElement;
|
||||||
|
const menu = this.refs.torrentContextMenu;
|
||||||
|
|
||||||
|
// Position the menu
|
||||||
|
menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`;
|
||||||
|
menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`;
|
||||||
|
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideContextMenu() {
|
||||||
|
this.refs.torrentContextMenu.classList.add('hidden');
|
||||||
|
this.state.selectedTorrentContextMenu = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleContextAction(action) {
|
||||||
|
const torrent = this.state.selectedTorrentContextMenu;
|
||||||
|
if (!torrent) return;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
'copy-magnet': async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${torrent.hash}`);
|
||||||
|
window.decypharrUtils.createToast('Magnet link copied to clipboard');
|
||||||
|
} catch (error) {
|
||||||
|
window.decypharrUtils.createToast('Failed to copy magnet link', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'copy-name': async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(torrent.name);
|
||||||
|
window.decypharrUtils.createToast('Torrent name copied to clipboard');
|
||||||
|
} catch (error) {
|
||||||
|
window.decypharrUtils.createToast('Failed to copy torrent name', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'delete': async () => {
|
||||||
|
await this.deleteTorrent(torrent.hash, torrent.category, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (actions[action]) {
|
||||||
|
await actions[action]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTorrents() {
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
this.refs.refreshBtn.disabled = true;
|
||||||
|
this.refs.paginationInfo.textContent = 'Loading torrents...';
|
||||||
|
|
||||||
|
const response = await window.decypharrUtils.fetcher('/api/torrents');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch torrents');
|
||||||
|
|
||||||
|
const torrents = await response.json();
|
||||||
|
this.state.torrents = torrents;
|
||||||
|
this.state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
|
||||||
|
|
||||||
|
this.updateUI();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading torrents:', error);
|
||||||
|
window.decypharrUtils.createToast(`Error loading torrents: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
this.refs.refreshBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI() {
|
||||||
|
// Filter torrents
|
||||||
|
this.filterTorrents();
|
||||||
|
|
||||||
|
// Update category dropdown
|
||||||
|
this.updateCategoryFilter();
|
||||||
|
|
||||||
|
// Render torrents table
|
||||||
|
this.renderTorrents();
|
||||||
|
|
||||||
|
// Update pagination
|
||||||
|
this.updatePagination();
|
||||||
|
|
||||||
|
// Update selection state
|
||||||
|
this.updateSelectionUI();
|
||||||
|
|
||||||
|
// Show/hide empty state
|
||||||
|
this.toggleEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
filterTorrents() {
|
||||||
|
let filtered = [...this.state.torrents];
|
||||||
|
|
||||||
|
if (this.state.selectedCategory) {
|
||||||
|
filtered = filtered.filter(t => t.category === this.state.selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.selectedState) {
|
||||||
|
filtered = filtered.filter(t => t.state?.toLowerCase() === this.state.selectedState.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort torrents
|
||||||
|
filtered = this.sortTorrents(filtered);
|
||||||
|
|
||||||
|
this.state.filteredTorrents = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortTorrents(torrents) {
|
||||||
|
const [field, direction] = this.state.sortBy.includes('_asc') || this.state.sortBy.includes('_desc')
|
||||||
|
? [this.state.sortBy.split('_').slice(0, -1).join('_'), this.state.sortBy.endsWith('_asc') ? 'asc' : 'desc']
|
||||||
|
: [this.state.sortBy, 'desc'];
|
||||||
|
|
||||||
|
return torrents.sort((a, b) => {
|
||||||
|
let valueA, valueB;
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
valueA = a.name?.toLowerCase() || '';
|
||||||
|
valueB = b.name?.toLowerCase() || '';
|
||||||
|
break;
|
||||||
|
case 'size':
|
||||||
|
valueA = a.size || 0;
|
||||||
|
valueB = b.size || 0;
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
valueA = a.progress || 0;
|
||||||
|
valueB = b.progress || 0;
|
||||||
|
break;
|
||||||
|
case 'added_on':
|
||||||
|
valueA = a.added_on || 0;
|
||||||
|
valueB = b.added_on || 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
valueA = a[field] || 0;
|
||||||
|
valueB = b[field] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof valueA === 'string') {
|
||||||
|
return direction === 'asc'
|
||||||
|
? valueA.localeCompare(valueB)
|
||||||
|
: valueB.localeCompare(valueA);
|
||||||
|
} else {
|
||||||
|
return direction === 'asc'
|
||||||
|
? valueA - valueB
|
||||||
|
: valueB - valueA;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTorrents() {
|
||||||
|
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length);
|
||||||
|
const pageItems = this.state.filteredTorrents.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
this.refs.torrentsList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
torrentRowTemplate(torrent) {
|
||||||
|
const progressPercent = (torrent.progress * 100).toFixed(1);
|
||||||
|
const isSelected = this.state.selectedTorrents.has(torrent.hash);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-hash="${torrent.hash}"
|
||||||
|
data-name="${this.escapeHtml(torrent.name)}"
|
||||||
|
data-category="${torrent.category || ''}"
|
||||||
|
class="hover:bg-base-200 transition-colors">
|
||||||
|
<td>
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="checkbox checkbox-sm torrent-select"
|
||||||
|
data-hash="${torrent.hash}"
|
||||||
|
${isSelected ? 'checked' : ''}>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="max-w-xs">
|
||||||
|
<div class="truncate font-medium" title="${this.escapeHtml(torrent.name)}">
|
||||||
|
${this.escapeHtml(torrent.name)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap font-mono text-sm">
|
||||||
|
${window.decypharrUtils.formatBytes(torrent.size)}
|
||||||
|
</td>
|
||||||
|
<td class="min-w-36">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<progress class="progress progress-primary w-20 h-2"
|
||||||
|
value="${progressPercent}"
|
||||||
|
max="100"></progress>
|
||||||
|
<span class="text-sm font-medium min-w-12">${progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap font-mono text-sm">
|
||||||
|
${window.decypharrUtils.formatSpeed(torrent.dlspeed)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${torrent.category ?
|
||||||
|
`<div class="badge badge-secondary badge-sm">${this.escapeHtml(torrent.category)}</div>` :
|
||||||
|
'<span class="text-base-content/50">None</span>'
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${torrent.debrid ?
|
||||||
|
`<div class="badge badge-accent badge-sm">${this.escapeHtml(torrent.debrid)}</div>` :
|
||||||
|
'<span class="text-base-content/50">None</span>'
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="badge ${this.getStateColor(torrent.state)} badge-sm">
|
||||||
|
${this.escapeHtml(torrent.state)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button class="btn btn-error btn-outline btn-xs tooltip"
|
||||||
|
onclick="dashboard.deleteTorrent('${torrent.hash}', '${torrent.category || ''}', false);"
|
||||||
|
data-tip="Delete from local">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
${torrent.debrid && torrent.id ? `
|
||||||
|
<button class="btn btn-error btn-outline btn-xs tooltip"
|
||||||
|
onclick="dashboard.deleteTorrent('${torrent.hash}', '${torrent.category || ''}', true);"
|
||||||
|
data-tip="Remove from ${torrent.debrid}">
|
||||||
|
<i class="bi bi-cloud-slash"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateColor(state) {
|
||||||
|
const stateColors = {
|
||||||
|
'downloading': 'badge-primary',
|
||||||
|
'pausedup': 'badge-success',
|
||||||
|
'error': 'badge-error',
|
||||||
|
'completed': 'badge-success'
|
||||||
|
};
|
||||||
|
return stateColors[state?.toLowerCase()] || 'badge-ghost';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategoryFilter() {
|
||||||
|
const currentCategories = Array.from(this.state.categories).sort();
|
||||||
|
const categoryOptions = ['<option value="">All Categories</option>']
|
||||||
|
.concat(currentCategories.map(cat =>
|
||||||
|
`<option value="${this.escapeHtml(cat)}" ${cat === this.state.selectedCategory ? 'selected' : ''}>
|
||||||
|
${this.escapeHtml(cat)}
|
||||||
|
</option>`
|
||||||
|
));
|
||||||
|
this.refs.categoryFilter.innerHTML = categoryOptions.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePagination() {
|
||||||
|
const totalPages = Math.ceil(this.state.filteredTorrents.length / this.state.itemsPerPage);
|
||||||
|
const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage;
|
||||||
|
const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length);
|
||||||
|
|
||||||
|
// Update pagination info
|
||||||
|
this.refs.paginationInfo.textContent =
|
||||||
|
`Showing ${this.state.filteredTorrents.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredTorrents.length} torrents`;
|
||||||
|
|
||||||
|
// Clear pagination controls
|
||||||
|
this.refs.paginationControls.innerHTML = '';
|
||||||
|
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
const prevBtn = this.createPaginationButton('❮', this.state.currentPage - 1, this.state.currentPage === 1);
|
||||||
|
this.refs.paginationControls.appendChild(prevBtn);
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
const maxPageButtons = 5;
|
||||||
|
let startPage = Math.max(1, this.state.currentPage - Math.floor(maxPageButtons / 2));
|
||||||
|
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
|
||||||
|
|
||||||
|
if (endPage - startPage + 1 < maxPageButtons) {
|
||||||
|
startPage = Math.max(1, endPage - maxPageButtons + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const pageBtn = this.createPaginationButton(i, i, false, i === this.state.currentPage);
|
||||||
|
this.refs.paginationControls.appendChild(pageBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
const nextBtn = this.createPaginationButton('❯', this.state.currentPage + 1, this.state.currentPage === totalPages);
|
||||||
|
this.refs.paginationControls.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPaginationButton(text, page, disabled = false, active = false) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = `join-item btn btn-sm ${active ? 'btn-active' : ''} ${disabled ? 'btn-disabled' : ''}`;
|
||||||
|
button.textContent = text;
|
||||||
|
button.disabled = disabled;
|
||||||
|
|
||||||
|
if (!disabled) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
this.state.currentPage = page;
|
||||||
|
this.updateUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectionUI() {
|
||||||
|
// Clean up selected torrents that no longer exist
|
||||||
|
const currentHashes = new Set(this.state.filteredTorrents.map(t => t.hash));
|
||||||
|
this.state.selectedTorrents.forEach(hash => {
|
||||||
|
if (!currentHashes.has(hash)) {
|
||||||
|
this.state.selectedTorrents.delete(hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update batch delete button
|
||||||
|
this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedTorrents.size === 0);
|
||||||
|
|
||||||
|
// Update select all checkbox
|
||||||
|
const visibleTorrents = this.state.filteredTorrents.slice(
|
||||||
|
(this.state.currentPage - 1) * this.state.itemsPerPage,
|
||||||
|
this.state.currentPage * this.state.itemsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
this.refs.selectAll.checked = visibleTorrents.length > 0 &&
|
||||||
|
visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash));
|
||||||
|
this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedTorrents.has(torrent.hash)) &&
|
||||||
|
!visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEmptyState() {
|
||||||
|
const isEmpty = this.state.torrents.length === 0;
|
||||||
|
this.refs.emptyState.classList.toggle('hidden', !isEmpty);
|
||||||
|
document.querySelector('.card:has(#torrentsList)').classList.toggle('hidden', isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
setFilter(type, value) {
|
||||||
|
if (type === 'category') {
|
||||||
|
this.state.selectedCategory = value;
|
||||||
|
} else if (type === 'state') {
|
||||||
|
this.state.selectedState = value;
|
||||||
|
}
|
||||||
|
this.state.currentPage = 1;
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSort(sortBy) {
|
||||||
|
this.state.sortBy = sortBy;
|
||||||
|
this.state.currentPage = 1;
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll(checked) {
|
||||||
|
const visibleTorrents = this.state.filteredTorrents.slice(
|
||||||
|
(this.state.currentPage - 1) * this.state.itemsPerPage,
|
||||||
|
this.state.currentPage * this.state.itemsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
visibleTorrents.forEach(torrent => {
|
||||||
|
if (checked) {
|
||||||
|
this.state.selectedTorrents.add(torrent.hash);
|
||||||
|
} else {
|
||||||
|
this.state.selectedTorrents.delete(torrent.hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTorrentSelection(hash, checked) {
|
||||||
|
if (checked) {
|
||||||
|
this.state.selectedTorrents.add(hash);
|
||||||
|
} else {
|
||||||
|
this.state.selectedTorrents.delete(hash);
|
||||||
|
}
|
||||||
|
this.updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTorrent(hash, category, removeFromDebrid = false) {
|
||||||
|
if (!confirm(`Are you sure you want to delete this torrent${removeFromDebrid ? ' from ' + category : ''}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = `/api/torrents/${encodeURIComponent(category)}/${hash}?removeFromDebrid=${removeFromDebrid}`;
|
||||||
|
const response = await window.decypharrUtils.fetcher(endpoint, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
|
||||||
|
window.decypharrUtils.createToast('Torrent deleted successfully');
|
||||||
|
await this.loadTorrents();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting torrent:', error);
|
||||||
|
window.decypharrUtils.createToast(`Failed to delete torrent: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSelectedTorrents() {
|
||||||
|
const count = this.state.selectedTorrents.size;
|
||||||
|
if (!confirm(`Are you sure you want to delete ${count} selected torrent${count > 1 ? 's' : ''}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hashes = Array.from(this.state.selectedTorrents).join(',');
|
||||||
|
const response = await window.decypharrUtils.fetcher(
|
||||||
|
`/api/torrents/?hashes=${encodeURIComponent(hashes)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
|
||||||
|
window.decypharrUtils.createToast(`${count} torrent${count > 1 ? 's' : ''} deleted successfully`);
|
||||||
|
this.state.selectedTorrents.clear();
|
||||||
|
await this.loadTorrents();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting torrents:', error);
|
||||||
|
window.decypharrUtils.createToast(`Failed to delete some torrents: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startAutoRefresh() {
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.loadTorrents();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text ? text.replace(/[&<>"']/g, (m) => map[m]) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize dashboard when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.dashboard = new TorrentDashboard();
|
||||||
|
});
|
||||||
250
pkg/web/assets/js/download.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// Download page functionality
|
||||||
|
class DownloadManager {
|
||||||
|
constructor() {
|
||||||
|
this.downloadFolder = '{{ .DownloadFolder }}' || '';
|
||||||
|
this.refs = {
|
||||||
|
downloadForm: document.getElementById('downloadForm'),
|
||||||
|
magnetURI: document.getElementById('magnetURI'),
|
||||||
|
torrentFiles: document.getElementById('torrentFiles'),
|
||||||
|
arr: document.getElementById('arr'),
|
||||||
|
downloadAction: document.getElementById('downloadAction'),
|
||||||
|
downloadUncached: document.getElementById('downloadUncached'),
|
||||||
|
downloadFolder: document.getElementById('downloadFolder'),
|
||||||
|
debrid: document.getElementById('debrid'),
|
||||||
|
submitBtn: document.getElementById('submitDownload'),
|
||||||
|
activeCount: document.getElementById('activeCount'),
|
||||||
|
completedCount: document.getElementById('completedCount'),
|
||||||
|
totalSize: document.getElementById('totalSize')
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadSavedOptions();
|
||||||
|
this.bindEvents();
|
||||||
|
this.handleMagnetFromURL();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Form submission
|
||||||
|
this.refs.downloadForm.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||||
|
|
||||||
|
// Save options on change
|
||||||
|
this.refs.arr.addEventListener('change', () => this.saveOptions());
|
||||||
|
this.refs.downloadAction.addEventListener('change', () => this.saveOptions());
|
||||||
|
this.refs.downloadUncached.addEventListener('change', () => this.saveOptions());
|
||||||
|
this.refs.downloadFolder.addEventListener('change', () => this.saveOptions());
|
||||||
|
|
||||||
|
// File input enhancement
|
||||||
|
this.refs.torrentFiles.addEventListener('change', (e) => this.handleFileSelection(e));
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
this.setupDragAndDrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSavedOptions() {
|
||||||
|
const savedOptions = {
|
||||||
|
category: localStorage.getItem('downloadCategory') || '',
|
||||||
|
action: localStorage.getItem('downloadAction') || 'symlink',
|
||||||
|
uncached: localStorage.getItem('downloadUncached') === 'true',
|
||||||
|
folder: localStorage.getItem('downloadFolder') || this.downloadFolder
|
||||||
|
};
|
||||||
|
|
||||||
|
this.refs.arr.value = savedOptions.category;
|
||||||
|
this.refs.downloadAction.value = savedOptions.action;
|
||||||
|
this.refs.downloadUncached.checked = savedOptions.uncached;
|
||||||
|
this.refs.downloadFolder.value = savedOptions.folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOptions() {
|
||||||
|
localStorage.setItem('downloadCategory', this.refs.arr.value);
|
||||||
|
localStorage.setItem('downloadAction', this.refs.downloadAction.value);
|
||||||
|
localStorage.setItem('downloadUncached', this.refs.downloadUncached.checked.toString());
|
||||||
|
localStorage.setItem('downloadFolder', this.refs.downloadFolder.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMagnetFromURL() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const magnetURI = urlParams.get('magnet');
|
||||||
|
|
||||||
|
if (magnetURI) {
|
||||||
|
this.refs.magnetURI.value = magnetURI;
|
||||||
|
history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
window.decypharrUtils.createToast('Magnet link loaded from URL', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Get URLs
|
||||||
|
const urls = this.refs.magnetURI.value
|
||||||
|
.split('\n')
|
||||||
|
.map(url => url.trim())
|
||||||
|
.filter(url => url.length > 0);
|
||||||
|
|
||||||
|
if (urls.length > 0) {
|
||||||
|
formData.append('urls', urls.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get files
|
||||||
|
for (let i = 0; i < this.refs.torrentFiles.files.length; i++) {
|
||||||
|
formData.append('files', this.refs.torrentFiles.files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const totalItems = urls.length + this.refs.torrentFiles.files.length;
|
||||||
|
if (totalItems === 0) {
|
||||||
|
window.decypharrUtils.createToast('Please provide at least one torrent', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalItems > 100) {
|
||||||
|
window.decypharrUtils.createToast('Please submit up to 100 torrents at a time', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other form data
|
||||||
|
formData.append('arr', this.refs.arr.value);
|
||||||
|
formData.append('downloadFolder', this.refs.downloadFolder.value);
|
||||||
|
formData.append('action', this.refs.downloadAction.value);
|
||||||
|
formData.append('downloadUncached', this.refs.downloadUncached.checked);
|
||||||
|
|
||||||
|
if (this.refs.debrid) {
|
||||||
|
formData.append('debrid', this.refs.debrid.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set loading state
|
||||||
|
window.decypharrUtils.setButtonLoading(this.refs.submitBtn, true);
|
||||||
|
|
||||||
|
const response = await window.decypharrUtils.fetcher('/api/add', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {} // Remove Content-Type to let browser set it for FormData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle partial success
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
if (result.results.length > 0) {
|
||||||
|
window.decypharrUtils.createToast(
|
||||||
|
`Added ${result.results.length} torrents with ${result.errors.length} errors`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
this.showErrorDetails(result.errors);
|
||||||
|
} else {
|
||||||
|
window.decypharrUtils.createToast('Failed to add torrents', 'error');
|
||||||
|
this.showErrorDetails(result.errors);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.decypharrUtils.createToast(
|
||||||
|
`Successfully added ${result.results.length} torrent${result.results.length > 1 ? 's' : ''}!`
|
||||||
|
);
|
||||||
|
this.clearForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding downloads:', error);
|
||||||
|
window.decypharrUtils.createToast(`Error adding downloads: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
window.decypharrUtils.setButtonLoading(this.refs.submitBtn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showErrorDetails(errors) {
|
||||||
|
// Create a modal or detailed view for errors
|
||||||
|
const errorList = errors.map(error => `• ${error}`).join('\n');
|
||||||
|
console.error('Download errors:', errorList);
|
||||||
|
|
||||||
|
// You could also show this in a modal for better UX
|
||||||
|
setTimeout(() => {
|
||||||
|
if (confirm('Some torrents failed to add. Would you like to see the details?')) {
|
||||||
|
alert(errorList);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearForm() {
|
||||||
|
this.refs.magnetURI.value = '';
|
||||||
|
this.refs.torrentFiles.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileSelection(e) {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const fileNames = Array.from(files).map(f => f.name).join(', ');
|
||||||
|
window.decypharrUtils.createToast(
|
||||||
|
`Selected ${files.length} file${files.length > 1 ? 's' : ''}: ${fileNames}`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDragAndDrop() {
|
||||||
|
const dropZone = this.refs.downloadForm;
|
||||||
|
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, this.preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => this.highlight(dropZone), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => this.unhighlight(dropZone), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', (e) => this.handleDrop(e), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
highlight(element) {
|
||||||
|
element.classList.add('border-primary', 'border-2', 'border-dashed', 'bg-primary/5');
|
||||||
|
}
|
||||||
|
|
||||||
|
unhighlight(element) {
|
||||||
|
element.classList.remove('border-primary', 'border-2', 'border-dashed', 'bg-primary/5');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDrop(e) {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const files = dt.files;
|
||||||
|
|
||||||
|
// Filter for .torrent files
|
||||||
|
const torrentFiles = Array.from(files).filter(file =>
|
||||||
|
file.name.toLowerCase().endsWith('.torrent')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (torrentFiles.length > 0) {
|
||||||
|
// Create a new FileList-like object
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
torrentFiles.forEach(file => dataTransfer.items.add(file));
|
||||||
|
this.refs.torrentFiles.files = dataTransfer.files;
|
||||||
|
|
||||||
|
this.handleFileSelection({ target: { files: torrentFiles } });
|
||||||
|
} else {
|
||||||
|
window.decypharrUtils.createToast('Please drop .torrent files only', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize download manager when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.downloadManager = new DownloadManager();
|
||||||
|
});
|
||||||
1119
pkg/web/assets/js/repair.js
Normal file
256
pkg/web/assets/logo.svg
Normal file
|
After Width: | Height: | Size: 184 KiB |
@@ -2,12 +2,20 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (wb *Web) Routes() http.Handler {
|
func (wb *Web) Routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
staticFS, err := fs.Sub(content, "assets")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS))))
|
||||||
|
|
||||||
r.Get("/login", wb.LoginHandler)
|
r.Get("/login", wb.LoginHandler)
|
||||||
r.Post("/login", wb.LoginHandler)
|
r.Post("/login", wb.LoginHandler)
|
||||||
r.Get("/register", wb.RegisterHandler)
|
r.Get("/register", wb.RegisterHandler)
|
||||||
|
|||||||
@@ -1,185 +1,141 @@
|
|||||||
{{ define "download" }}
|
{{ define "download" }}
|
||||||
<div class="container mt-4">
|
<div class="space-y-6">
|
||||||
<div class="card">
|
<!-- Download Form -->
|
||||||
<div class="card-header">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<h4 class="mb-0"><i class="bi bi-cloud-download me-2"></i>Add New Download</h4>
|
<div class="card-body">
|
||||||
</div>
|
<form id="downloadForm" enctype="multipart/form-data" class="space-y-3">
|
||||||
<div class="card-body">
|
<!-- Torrent Input Section -->
|
||||||
<form id="downloadForm" enctype="multipart/form-data">
|
<div class="space-y-2">
|
||||||
<div class="mb-2">
|
<div class="form-control">
|
||||||
<label for="magnetURI" class="form-label">Torrent(s)</label>
|
<label class="label" for="magnetURI">
|
||||||
<textarea class="form-control" id="magnetURI" name="urls" rows="8" placeholder="Paste your magnet links or torrent URLs here, one per line..."></textarea>
|
<span class="label-text font-semibold">
|
||||||
|
<i class="bi bi-magnet mr-2 text-primary"></i>Torrent Links
|
||||||
|
</span>
|
||||||
|
<span class="label-text-alt">Paste magnet links or URLs</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="textarea textarea-bordered h-32 font-mono text-sm"
|
||||||
|
id="magnetURI"
|
||||||
|
name="urls"
|
||||||
|
placeholder="Paste your magnet links or torrent URLs here, one per line..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="divider">OR</div>
|
||||||
<input type="file" class="form-control" id="torrentFiles" name="torrents" multiple accept=".torrent,.magnet">
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">
|
||||||
|
<i class="bi bi-file-earmark-arrow-up mr-2 text-secondary"></i>Upload Torrent Files
|
||||||
|
</span>
|
||||||
|
<span class="label-text-alt">Select .torrent files</span>
|
||||||
|
</label>
|
||||||
|
<input type="file"
|
||||||
|
class="file-input file-input-bordered w-full"
|
||||||
|
id="torrentFiles"
|
||||||
|
name="torrents"
|
||||||
|
multiple
|
||||||
|
accept=".torrent">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">
|
||||||
|
<i class="bi bi-info-circle mr-1"></i>You can select multiple files at once
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div class="divider"></div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<!-- Configuration Section -->
|
||||||
<div class="col">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
<label for="downloadAction" class="form-label">Post Download Action</label>
|
<div class="space-y-2">
|
||||||
<select class="form-select" id="downloadAction" name="downloadAction">
|
<h3 class="text-lg font-semibold flex items-center">
|
||||||
<option value="symlink" selected>Symlink</option>
|
<i class="bi bi-gear mr-2 text-info"></i>Download Settings
|
||||||
<option value="download">Download</option>
|
</h3>
|
||||||
<option value="none">None</option>
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="downloadAction">
|
||||||
|
<span class="label-text">Post Download Action</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered" id="downloadAction" name="downloadAction">
|
||||||
|
<option value="symlink" selected>Create Symlink</option>
|
||||||
|
<option value="download">Download Files</option>
|
||||||
|
<option value="none">No Action</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="text-muted">Choose how to handle the added torrent (Default to symlinks)</small>
|
<div class="label">
|
||||||
</div>
|
<span class="label-text-alt">How to handle files after download completion</span>
|
||||||
<div class="col">
|
|
||||||
<label for="downloadFolder" class="form-label">Download Folder</label>
|
|
||||||
<input type="text" class="form-control" id="downloadFolder" name="downloadFolder" placeholder="Enter Download Folder (e.g /downloads/torrents)">
|
|
||||||
<small class="text-muted">Default is your qbittorent download_folder</small>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label for="arr" class="form-label">Arr (if any)</label>
|
|
||||||
<input type="text" class="form-control" id="arr" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
|
|
||||||
<small class="text-muted">Optional, leave empty if not using Arr</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ if .HasMultiDebrid }}
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="debrid" class="form-label">Select Debrid</label>
|
|
||||||
<select class="form-select" id="debrid" name="debrid">
|
|
||||||
{{ range $index, $debrid := .Debrids }}
|
|
||||||
<option value="{{ $debrid }}" {{ if eq $index 0 }}selected{{end}}>{{ $debrid }}</option>
|
|
||||||
{{ end }}
|
|
||||||
</select>
|
|
||||||
<small class="text-muted">Select a debrid service to use for this download</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-md-2 mb-3">
|
|
||||||
<div class="form-check d-inline-block">
|
|
||||||
<input type="checkbox" class="form-check-input" name="downloadUncached" id="downloadUncached">
|
|
||||||
<label class="form-check-label" for="downloadUncached">Download Uncached</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="downloadFolder">
|
||||||
|
<span class="label-text">Download Folder</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
id="downloadFolder"
|
||||||
|
name="downloadFolder"
|
||||||
|
placeholder="/downloads/torrents">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Leave empty to use default qBittorrent folder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" id="submitDownload">
|
<div class="space-y-2">
|
||||||
<i class="bi bi-cloud-upload me-2"></i>Add to Download Queue
|
<h3 class="text-lg font-semibold flex items-center">
|
||||||
|
<i class="bi bi-tags mr-2 text-warning"></i>Categorization
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="arr">
|
||||||
|
<span class="label-text">Arr Category</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
id="arr"
|
||||||
|
name="arr"
|
||||||
|
placeholder="sonarr, radarr, etc.">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Optional: Specify which Arr service should handle this</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .HasMultiDebrid }}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="debrid">
|
||||||
|
<span class="label-text">Debrid Service</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered" id="debrid" name="debrid">
|
||||||
|
{{ range $index, $debrid := .Debrids }}
|
||||||
|
<option value="{{ $debrid }}" {{ if eq $index 0 }}selected{{end}}>
|
||||||
|
{{ $debrid }}
|
||||||
|
</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Choose which debrid service to use</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input type="checkbox" class="checkbox" name="downloadUncached" id="downloadUncached">
|
||||||
|
<div>
|
||||||
|
<span class="label-text font-medium">Download Uncached Content</span>
|
||||||
|
<div class="label-text-alt">Allow downloading of content not cached by debrid service</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="form-control">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg" id="submitDownload">
|
||||||
|
<i class="bi bi-cloud-upload mr-2"></i>Add to Download Queue
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
|
||||||
let downloadFolder = '{{ .DownloadFolder }}';
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const loadSavedDownloadOptions = () => {
|
|
||||||
const savedCategory = localStorage.getItem('downloadCategory');
|
|
||||||
const savedAction = localStorage.getItem('downloadAction');
|
|
||||||
const savedDownloadUncached = localStorage.getItem('downloadUncached');
|
|
||||||
document.getElementById('arr').value = savedCategory || '';
|
|
||||||
document.getElementById('downloadAction').value = savedAction || 'symlink';
|
|
||||||
document.getElementById('downloadUncached').checked = savedDownloadUncached === 'true';
|
|
||||||
document.getElementById('downloadFolder').value = localStorage.getItem('downloadFolder') || downloadFolder || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCurrentDownloadOptions = () => {
|
|
||||||
const arr = document.getElementById('arr').value;
|
|
||||||
const downloadAction = document.getElementById('downloadAction').value;
|
|
||||||
const downloadUncached = document.getElementById('downloadUncached').checked;
|
|
||||||
const downloadFolder = document.getElementById('downloadFolder').value;
|
|
||||||
localStorage.setItem('downloadCategory', arr);
|
|
||||||
localStorage.setItem('downloadAction', downloadAction);
|
|
||||||
localStorage.setItem('downloadUncached', downloadUncached.toString());
|
|
||||||
localStorage.setItem('downloadFolder', downloadFolder);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load the last used download options from local storage
|
|
||||||
loadSavedDownloadOptions();
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const submitBtn = document.getElementById('submitDownload');
|
|
||||||
const originalText = submitBtn.innerHTML;
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
// Add URLs if present
|
|
||||||
const urls = document.getElementById('magnetURI').value
|
|
||||||
.split('\n')
|
|
||||||
.map(url => url.trim())
|
|
||||||
.filter(url => url.length > 0);
|
|
||||||
|
|
||||||
if (urls.length > 0) {
|
|
||||||
formData.append('urls', urls.join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add torrent files if present
|
|
||||||
const fileInput = document.getElementById('torrentFiles');
|
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
|
||||||
formData.append('files', fileInput.files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urls.length + fileInput.files.length === 0) {
|
|
||||||
createToast('Please submit at least one torrent', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urls.length + fileInput.files.length > 100) {
|
|
||||||
createToast('Please submit up to 100 torrents at a time', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append('arr', document.getElementById('arr').value);
|
|
||||||
formData.append('downloadFolder', document.getElementById('downloadFolder').value);
|
|
||||||
formData.append('action', document.getElementById('downloadAction').value);
|
|
||||||
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
|
||||||
formData.append('debrid', document.getElementById('debrid') ? document.getElementById('debrid').value : '');
|
|
||||||
|
|
||||||
const response = await fetcher('/api/add', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw new Error(result.error || 'Unknown error');
|
|
||||||
if (result.errors && result.errors.length > 0) {
|
|
||||||
if (result.results.length > 0) {
|
|
||||||
createToast(`Added ${result.results.length} torrents with ${result.errors.length} errors:\n${result.errors.join('\n')}`, 'warning');
|
|
||||||
} else {
|
|
||||||
createToast(`Failed to add torrents:\n${result.errors.join('\n')}`, 'error');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
createToast(`Successfully added ${result.results.length} torrents!`);
|
|
||||||
document.getElementById('magnetURI').value = '';
|
|
||||||
document.getElementById('torrentFiles').value = '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
createToast(`Error adding downloads: ${error.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save the download options to local storage when they change
|
|
||||||
document.getElementById('arr').addEventListener('change', saveCurrentDownloadOptions);
|
|
||||||
document.getElementById('downloadAction').addEventListener('change', saveCurrentDownloadOptions);
|
|
||||||
|
|
||||||
// Read the URL parameters for a magnet link and add it to the download queue if found
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const magnetURI = urlParams.get('magnet');
|
|
||||||
if (magnetURI) {
|
|
||||||
document.getElementById('magnetURI').value = magnetURI;
|
|
||||||
history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
{{ define "index" }}
|
{{ define "index" }}
|
||||||
<div class="container mt-4">
|
<div class="space-y-6">
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center gap-4">
|
<!-- Controls Section -->
|
||||||
<h4 class="mb-0 text-nowrap"><i class="bi bi-table me-2"></i>Active Torrents</h4>
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="d-flex align-items-center overflow-auto" style="flex-wrap: nowrap; gap: 0.5rem;">
|
<div class="card-body">
|
||||||
<button class="btn btn-outline-danger btn-sm" id="batchDeleteBtn" style="display: none; flex-shrink: 0;">
|
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
<!-- Batch Actions -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button class="btn btn-error btn-sm hidden" id="batchDeleteBtn">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
<span class="hidden sm:inline">Delete Selected</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn" style="flex-shrink: 0;">
|
<button class="btn btn-outline btn-sm" id="refreshBtn">
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
<span class="hidden sm:inline">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 w-full lg:w-auto">
|
||||||
|
<select class="select select-bordered select-sm w-full sm:w-auto min-w-32" id="stateFilter">
|
||||||
<option value="">All States</option>
|
<option value="">All States</option>
|
||||||
<option value="pausedUP">PausedUP(Completed)</option>
|
<option value="pausedUP">Completed</option>
|
||||||
<option value="downloading">Downloading</option>
|
<option value="downloading">Downloading</option>
|
||||||
<option value="error">Error</option>
|
<option value="error">Error</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
|
<select class="select select-bordered select-sm w-full sm:w-auto min-w-32" id="categoryFilter">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="form-select form-select-sm d-inline-block w-auto" id="sortSelector" style="flex-shrink: 0;">
|
<select class="select select-bordered select-sm w-full sm:w-auto min-w-48" id="sortSelector">
|
||||||
<option value="added_on" selected>Date Added (Newest First)</option>
|
<option value="added_on" selected>Date Added (Newest First)</option>
|
||||||
<option value="added_on_asc">Date Added (Oldest First)</option>
|
<option value="added_on_asc">Date Added (Oldest First)</option>
|
||||||
<option value="name_asc">Name (A-Z)</option>
|
<option value="name_asc">Name (A-Z)</option>
|
||||||
@@ -31,470 +40,91 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
</div>
|
||||||
<div class="table-responsive">
|
</div>
|
||||||
<table class="table table-hover mb-0">
|
|
||||||
<thead>
|
<!-- Torrents Table -->
|
||||||
<tr>
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<th>
|
<div class="card-body p-0">
|
||||||
<input type="checkbox" class="form-check-input" id="selectAll">
|
<div class="overflow-x-auto">
|
||||||
</th>
|
<table class="table table-hover">
|
||||||
<th>Name</th>
|
<thead class="bg-base-200">
|
||||||
<th>Size</th>
|
<tr>
|
||||||
<th>Progress</th>
|
<th class="w-12">
|
||||||
<th>Speed</th>
|
<label class="cursor-pointer">
|
||||||
<th>Category</th>
|
<input type="checkbox" class="checkbox checkbox-sm" id="selectAll">
|
||||||
<th>Debrid</th>
|
</label>
|
||||||
<th>State</th>
|
</th>
|
||||||
<th>Actions</th>
|
<th class="font-semibold">
|
||||||
</tr>
|
<i class="bi bi-file-text mr-2"></i>Name
|
||||||
</thead>
|
</th>
|
||||||
<tbody id="torrentsList">
|
<th class="font-semibold">
|
||||||
</tbody>
|
<i class="bi bi-hdd mr-2"></i>Size
|
||||||
</table>
|
</th>
|
||||||
</div>
|
<th class="font-semibold">
|
||||||
<div class="d-flex justify-content-between align-items-center p-3 border-top">
|
<i class="bi bi-speedometer2 mr-2"></i>Progress
|
||||||
<div class="pagination-info">
|
</th>
|
||||||
<span id="paginationInfo">Showing 0-0 of 0 torrents</span>
|
<th class="font-semibold">
|
||||||
</div>
|
<i class="bi bi-download mr-2"></i>Speed
|
||||||
<nav aria-label="Torrents pagination">
|
</th>
|
||||||
<ul class="pagination pagination-sm m-0" id="paginationControls"></ul>
|
<th class="font-semibold">
|
||||||
</nav>
|
<i class="bi bi-tag mr-2"></i>Category
|
||||||
|
</th>
|
||||||
|
<th class="font-semibold">
|
||||||
|
<i class="bi bi-cloud mr-2"></i>Debrid
|
||||||
|
</th>
|
||||||
|
<th class="font-semibold">
|
||||||
|
<i class="bi bi-activity mr-2"></i>State
|
||||||
|
</th>
|
||||||
|
<th class="font-semibold w-32">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="torrentsList">
|
||||||
|
<!-- Dynamic content will be loaded here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-center p-6 border-t border-base-200 gap-4">
|
||||||
|
<div class="text-sm text-base-content/70">
|
||||||
|
<span id="paginationInfo">Loading torrents...</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="join" id="paginationControls"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context menu for torrent rows -->
|
<!-- Empty State -->
|
||||||
<div class="dropdown-menu context-menu shadow" id="torrentContextMenu">
|
<div class="card bg-base-100 shadow-xl hidden" id="emptyState">
|
||||||
<h6 class="dropdown-header torrent-name text-truncate"></h6>
|
<div class="card-body text-center py-16">
|
||||||
<div class="dropdown-divider"></div>
|
<div class="text-6xl text-base-content/30 mb-4">
|
||||||
<button class="dropdown-item" data-action="copy-magnet">
|
<i class="bi bi-inbox"></i>
|
||||||
<i class="bi bi-magnet me-2"></i>Copy Magnet Link
|
</div>
|
||||||
</button>
|
<h3 class="text-2xl font-bold mb-2">No Torrents Found</h3>
|
||||||
<button class="dropdown-item" data-action="copy-name">
|
<p class="text-base-content/70 mb-6">You haven't added any torrents yet. Start by adding your first download!</p>
|
||||||
<i class="bi bi-copy me-2"></i>Copy Name
|
<a href="{{.URLBase}}download" class="btn btn-primary">
|
||||||
</button>
|
<i class="bi bi-plus-circle mr-2"></i>Add New Download
|
||||||
<div class="dropdown-divider"></div>
|
</a>
|
||||||
<button class="dropdown-item text-danger" data-action="delete">
|
</div>
|
||||||
<i class="bi bi-trash me-2"></i>Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Context Menu -->
|
||||||
let refs = {
|
<ul class="menu bg-base-100 shadow-lg rounded-box context-menu hidden fixed z-50" id="torrentContextMenu">
|
||||||
torrentsList: document.getElementById('torrentsList'),
|
<li class="menu-title">
|
||||||
categoryFilter: document.getElementById('categoryFilter'),
|
<span class="torrent-name text-sm font-bold truncate max-w-48"></span>
|
||||||
stateFilter: document.getElementById('stateFilter'),
|
</li>
|
||||||
sortSelector: document.getElementById('sortSelector'),
|
<li><hr class="my-1"></li>
|
||||||
selectAll: document.getElementById('selectAll'),
|
<li><a class="menu-item text-sm" data-action="copy-magnet">
|
||||||
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
|
<i class="bi bi-magnet text-primary"></i>Copy Magnet Link
|
||||||
refreshBtn: document.getElementById('refreshBtn'),
|
</a></li>
|
||||||
torrentContextMenu: document.getElementById('torrentContextMenu'),
|
<li><a class="menu-item text-sm" data-action="copy-name">
|
||||||
paginationControls: document.getElementById('paginationControls'),
|
<i class="bi bi-clipboard text-info"></i>Copy Name
|
||||||
paginationInfo: document.getElementById('paginationInfo')
|
</a></li>
|
||||||
};
|
<li><hr class="my-1"></li>
|
||||||
let state = {
|
<li><a class="menu-item text-sm text-error" data-action="delete">
|
||||||
torrents: [],
|
<i class="bi bi-trash"></i>Delete Torrent
|
||||||
selectedTorrents: new Set(),
|
</a></li>
|
||||||
categories: new Set(),
|
</ul>
|
||||||
states: new Set('downloading', 'pausedUP', 'error'),
|
|
||||||
selectedCategory: refs.categoryFilter?.value || '',
|
|
||||||
selectedState: refs.stateFilter?.value || '',
|
|
||||||
selectedTorrentContextMenu: null,
|
|
||||||
sortBy: refs.sortSelector?.value || 'added_on',
|
|
||||||
itemsPerPage: 20,
|
|
||||||
currentPage: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const torrentRowTemplate = (torrent) => `
|
|
||||||
<tr data-hash="${torrent.hash}" data-magnet="${torrent.magnet || ''}" data-name="${torrent.name}">
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" class="form-check-input torrent-select" data-hash="${torrent.hash}" ${state.selectedTorrents.has(torrent.hash) ? 'checked' : ''}>
|
|
||||||
</td>
|
|
||||||
<td class="text-nowrap text-truncate overflow-hidden" style="max-width: 350px;" title="${torrent.name}">${torrent.name}</td>
|
|
||||||
<td class="text-nowrap">${formatBytes(torrent.size)}</td>
|
|
||||||
<td style="min-width: 150px;">
|
|
||||||
<div class="progress" style="height: 8px;">
|
|
||||||
<div class="progress-bar" role="progressbar"
|
|
||||||
style="width: ${(torrent.progress * 100).toFixed(1)}%"
|
|
||||||
aria-valuenow="${(torrent.progress * 100).toFixed(1)}"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"></div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">${(torrent.progress * 100).toFixed(1)}%</small>
|
|
||||||
</td>
|
|
||||||
<td>${formatSpeed(torrent.dlspeed)}</td>
|
|
||||||
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
|
|
||||||
<td>${torrent.debrid || 'None'}</td>
|
|
||||||
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
|
|
||||||
<td>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category || ''}', false)">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
${torrent.debrid && torrent.id ? `
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category || ''}', true)">
|
|
||||||
<i class="bi bi-trash"></i> Remove from Debrid
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (!bytes) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSpeed(speed) {
|
|
||||||
return `${formatBytes(speed)}/s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateColor(state) {
|
|
||||||
const stateColors = {
|
|
||||||
'downloading': 'bg-primary',
|
|
||||||
'pausedup': 'bg-success',
|
|
||||||
'error': 'bg-danger',
|
|
||||||
};
|
|
||||||
return stateColors[state?.toLowerCase()] || 'bg-secondary';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUI() {
|
|
||||||
// Filter torrents by selected category and state
|
|
||||||
let filteredTorrents = state.torrents;
|
|
||||||
if (state.selectedCategory) {
|
|
||||||
filteredTorrents = filteredTorrents.filter(t => t.category === state.selectedCategory);
|
|
||||||
}
|
|
||||||
if (state.selectedState) {
|
|
||||||
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the filtered torrents
|
|
||||||
filteredTorrents = sortTorrents(filteredTorrents, state.sortBy);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredTorrents.length / state.itemsPerPage);
|
|
||||||
if (state.currentPage > totalPages && totalPages > 0) {
|
|
||||||
state.currentPage = totalPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paginatedTorrents = paginateTorrents(filteredTorrents);
|
|
||||||
|
|
||||||
// Update the torrents list table
|
|
||||||
refs.torrentsList.innerHTML = paginatedTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
|
|
||||||
|
|
||||||
|
|
||||||
// Update the category filter dropdown
|
|
||||||
const currentCategories = Array.from(state.categories).sort();
|
|
||||||
const categoryOptions = ['<option value="">All Categories</option>']
|
|
||||||
.concat(currentCategories.map(cat =>
|
|
||||||
`<option value="${cat}" ${cat === state.selectedCategory ? 'selected' : ''}>${cat}</option>`
|
|
||||||
));
|
|
||||||
refs.categoryFilter.innerHTML = categoryOptions.join('');
|
|
||||||
|
|
||||||
// Clean up selected torrents that no longer exist
|
|
||||||
state.selectedTorrents = new Set(
|
|
||||||
Array.from(state.selectedTorrents)
|
|
||||||
.filter(hash => filteredTorrents.some(t => t.hash === hash))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update batch delete button visibility
|
|
||||||
refs.batchDeleteBtn.style.display = state.selectedTorrents.size > 0 ? '' : 'none';
|
|
||||||
|
|
||||||
// Update the select all checkbox state
|
|
||||||
refs.selectAll.checked = filteredTorrents.length > 0 && filteredTorrents.every(torrent => state.selectedTorrents.has(torrent.hash));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTorrents() {
|
|
||||||
try {
|
|
||||||
const response = await fetcher('/api/torrents');
|
|
||||||
const torrents = await response.json();
|
|
||||||
|
|
||||||
state.torrents = torrents;
|
|
||||||
state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
|
|
||||||
|
|
||||||
updateUI();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading torrents:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTorrents(torrents, sortBy) {
|
|
||||||
// Create a copy of the array to avoid mutating the original
|
|
||||||
const result = [...torrents];
|
|
||||||
|
|
||||||
// Parse the sort value to determine field and direction
|
|
||||||
const [field, direction] = sortBy.includes('_asc') || sortBy.includes('_desc')
|
|
||||||
? [sortBy.split('_').slice(0, -1).join('_'), sortBy.endsWith('_asc') ? 'asc' : 'desc']
|
|
||||||
: [sortBy, 'desc']; // Default to descending if not specified
|
|
||||||
|
|
||||||
result.sort((a, b) => {
|
|
||||||
let valueA, valueB;
|
|
||||||
|
|
||||||
// Get values based on field
|
|
||||||
switch (field) {
|
|
||||||
case 'name':
|
|
||||||
valueA = a.name?.toLowerCase() || '';
|
|
||||||
valueB = b.name?.toLowerCase() || '';
|
|
||||||
break;
|
|
||||||
case 'size':
|
|
||||||
valueA = a.size || 0;
|
|
||||||
valueB = b.size || 0;
|
|
||||||
break;
|
|
||||||
case 'progress':
|
|
||||||
valueA = a.progress || 0;
|
|
||||||
valueB = b.progress || 0;
|
|
||||||
break;
|
|
||||||
case 'added_on':
|
|
||||||
valueA = a.added_on || 0;
|
|
||||||
valueB = b.added_on || 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
valueA = a[field] || 0;
|
|
||||||
valueB = b[field] || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare based on type
|
|
||||||
if (typeof valueA === 'string') {
|
|
||||||
return direction === 'asc'
|
|
||||||
? valueA.localeCompare(valueB)
|
|
||||||
: valueB.localeCompare(valueA);
|
|
||||||
} else {
|
|
||||||
return direction === 'asc'
|
|
||||||
? valueA - valueB
|
|
||||||
: valueB - valueA;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTorrent(hash, category, removeFromDebrid = false) {
|
|
||||||
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetcher(`/api/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
await loadTorrents();
|
|
||||||
createToast('Torrent deleted successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting torrent:', error);
|
|
||||||
createToast('Failed to delete torrent', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSelectedTorrents() {
|
|
||||||
if (!confirm(`Are you sure you want to delete ${state.selectedTorrents.size} selected torrents?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// COmma separated list of hashes
|
|
||||||
const hashes = Array.from(state.selectedTorrents).join(',');
|
|
||||||
await fetcher(`/api/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
await loadTorrents();
|
|
||||||
createToast('Selected torrents deleted successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting torrents:', error);
|
|
||||||
createToast('Failed to delete some torrents' , 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paginateTorrents(torrents) {
|
|
||||||
const totalItems = torrents.length;
|
|
||||||
const totalPages = Math.ceil(totalItems / state.itemsPerPage);
|
|
||||||
const startIndex = (state.currentPage - 1) * state.itemsPerPage;
|
|
||||||
const endIndex = Math.min(startIndex + state.itemsPerPage, totalItems);
|
|
||||||
|
|
||||||
// Update pagination info text
|
|
||||||
refs.paginationInfo.textContent =
|
|
||||||
`Showing ${totalItems > 0 ? startIndex + 1 : 0}-${endIndex} of ${totalItems} torrents`;
|
|
||||||
|
|
||||||
// Generate pagination controls
|
|
||||||
refs.paginationControls.innerHTML = '';
|
|
||||||
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
return torrents.slice(startIndex, endIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previous button
|
|
||||||
const prevLi = document.createElement('li');
|
|
||||||
prevLi.className = `page-item ${state.currentPage === 1 ? 'disabled' : ''}`;
|
|
||||||
prevLi.innerHTML = `
|
|
||||||
<a class="page-link" href="#" aria-label="Previous" ${state.currentPage === 1 ? 'tabindex="-1" aria-disabled="true"' : ''}>
|
|
||||||
<span aria-hidden="true">«</span>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
if (state.currentPage > 1) {
|
|
||||||
prevLi.querySelector('a').addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
state.currentPage--;
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
refs.paginationControls.appendChild(prevLi);
|
|
||||||
|
|
||||||
// Page numbers
|
|
||||||
const maxPageButtons = 5;
|
|
||||||
let startPage = Math.max(1, state.currentPage - Math.floor(maxPageButtons / 2));
|
|
||||||
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
|
|
||||||
|
|
||||||
if (endPage - startPage + 1 < maxPageButtons) {
|
|
||||||
startPage = Math.max(1, endPage - maxPageButtons + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
|
||||||
const pageLi = document.createElement('li');
|
|
||||||
pageLi.className = `page-item ${i === state.currentPage ? 'active' : ''}`;
|
|
||||||
pageLi.innerHTML = `<a class="page-link" href="#">${i}</a>`;
|
|
||||||
|
|
||||||
pageLi.querySelector('a').addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
state.currentPage = i;
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.paginationControls.appendChild(pageLi);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next button
|
|
||||||
const nextLi = document.createElement('li');
|
|
||||||
nextLi.className = `page-item ${state.currentPage === totalPages ? 'disabled' : ''}`;
|
|
||||||
nextLi.innerHTML = `
|
|
||||||
<a class="page-link" href="#" aria-label="Next" ${state.currentPage === totalPages ? 'tabindex="-1" aria-disabled="true"' : ''}>
|
|
||||||
<span aria-hidden="true">»</span>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
if (state.currentPage < totalPages) {
|
|
||||||
nextLi.querySelector('a').addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
state.currentPage++;
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
refs.paginationControls.appendChild(nextLi);
|
|
||||||
|
|
||||||
return torrents.slice(startIndex, endIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadTorrents();
|
|
||||||
const refreshInterval = setInterval(loadTorrents, 5000);
|
|
||||||
|
|
||||||
refs.refreshBtn.addEventListener('click', loadTorrents);
|
|
||||||
refs.batchDeleteBtn.addEventListener('click', deleteSelectedTorrents);
|
|
||||||
|
|
||||||
refs.selectAll.addEventListener('change', (e) => {
|
|
||||||
const filteredTorrents = state.torrents.filter(t => {
|
|
||||||
if (state.selectedCategory && t.category !== state.selectedCategory) return false;
|
|
||||||
if (state.selectedState && t.state?.toLowerCase() !== state.selectedState.toLowerCase()) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (e.target.checked) {
|
|
||||||
filteredTorrents.forEach(torrent => state.selectedTorrents.add(torrent.hash));
|
|
||||||
} else {
|
|
||||||
filteredTorrents.forEach(torrent => state.selectedTorrents.delete(torrent.hash));
|
|
||||||
}
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.torrentsList.addEventListener('change', (e) => {
|
|
||||||
if (e.target.classList.contains('torrent-select')) {
|
|
||||||
const hash = e.target.dataset.hash;
|
|
||||||
if (e.target.checked) {
|
|
||||||
state.selectedTorrents.add(hash);
|
|
||||||
} else {
|
|
||||||
state.selectedTorrents.delete(hash);
|
|
||||||
}
|
|
||||||
updateUI();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.categoryFilter.addEventListener('change', (e) => {
|
|
||||||
state.selectedCategory = e.target.value;
|
|
||||||
state.currentPage = 1; // Reset to first page
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.stateFilter.addEventListener('change', (e) => {
|
|
||||||
state.selectedState = e.target.value;
|
|
||||||
state.currentPage = 1; // Reset to first page
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.sortSelector.addEventListener('change', (e) => {
|
|
||||||
state.sortBy = e.target.value;
|
|
||||||
state.currentPage = 1; // Reset to first page
|
|
||||||
updateUI();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
clearInterval(refreshInterval);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!refs.torrentContextMenu.contains(e.target)) {
|
|
||||||
refs.torrentContextMenu.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.torrentsList.addEventListener('contextmenu', (e) => {
|
|
||||||
const row = e.target.closest('tr');
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
state.selectedTorrentContextMenu = row.dataset.hash;
|
|
||||||
|
|
||||||
refs.torrentContextMenu.querySelector('.torrent-name').textContent = row.dataset.name;
|
|
||||||
refs.torrentContextMenu.style.display = 'block';
|
|
||||||
|
|
||||||
const { pageX, pageY } = e;
|
|
||||||
const { clientWidth, clientHeight } = document.documentElement;
|
|
||||||
const { offsetWidth, offsetHeight } = refs.torrentContextMenu;
|
|
||||||
|
|
||||||
refs.torrentContextMenu.style.maxWidth = `${clientWidth - 72}px`;
|
|
||||||
refs.torrentContextMenu.style.left = `${Math.min(pageX, clientWidth - offsetWidth - 5)}px`;
|
|
||||||
refs.torrentContextMenu.style.top = `${Math.min(pageY, clientHeight - offsetHeight - 5)}px`;
|
|
||||||
});
|
|
||||||
|
|
||||||
refs.torrentContextMenu.addEventListener('click', async (e) => {
|
|
||||||
const action = e.target.closest('[data-action]')?.dataset.action;
|
|
||||||
if (!action) return;
|
|
||||||
|
|
||||||
const actions = {
|
|
||||||
'copy-magnet': async (torrent) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${torrent.hash}`);
|
|
||||||
createToast('Magnet link copied to clipboard');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error copying magnet link:', error);
|
|
||||||
createToast('Failed to copy magnet link', 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'copy-name': async (torrent) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(torrent.name);
|
|
||||||
createToast('Torrent name copied to clipboard');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error copying torrent name:', error);
|
|
||||||
createToast('Failed to copy torrent name', 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'delete': async (torrent) => {
|
|
||||||
await deleteTorrent(torrent.hash, torrent.category || '', false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const torrent = state.torrents.find(t => t.hash === state.selectedTorrentContextMenu);
|
|
||||||
if (torrent && actions[action]) {
|
|
||||||
await actions[action](torrent);
|
|
||||||
refs.torrentContextMenu.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -1,463 +1,206 @@
|
|||||||
{{ define "layout" }}
|
{{ define "layout" }}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-bs-theme="light">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Decypharr - {{.Title}}</title>
|
<title>Decypharr - {{.Title}}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary-color: #2563eb;
|
|
||||||
--secondary-color: #1e40af;
|
|
||||||
--bg-color: #f8fafc;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--text-color: #333333;
|
|
||||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
--border-color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
<!-- DaisyUI and Tailwind CSS -->
|
||||||
--primary-color: #3b82f6;
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||||
--secondary-color: #60a5fa;
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
--bg-color: #1e293b;
|
|
||||||
--card-bg: #283548;
|
|
||||||
--text-color: #e5e7eb;
|
|
||||||
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
||||||
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
||||||
--border-color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
<!-- Icons -->
|
||||||
background-color: var(--bg-color);
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
color: var(--text-color);
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
<!-- Custom Styles -->
|
||||||
background-color: var(--bg-color);
|
<link href="{{.URLBase}}assets/css/styles.css" rel="stylesheet">
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a {
|
<link rel="apple-touch-icon" sizes="180x180" href="{{.URLBase}}assetsfavicon/apple-touch-icon.png">
|
||||||
color: var(--text-color);
|
<link rel="icon" type="image/png" sizes="32x32" href="{{.URLBase}}assets/favicon/favicon-32x32.png">
|
||||||
}
|
<link rel="icon" type="image/png" sizes="16x16" href="{{.URLBase}}assets/favicon/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="{{.URLBase}}assets/favicon/site.webmanifest">
|
||||||
|
|
||||||
footer a:hover {
|
<!-- Preload JavaScript -->
|
||||||
color: var(--primary-color);
|
<link rel="preload" href="{{.URLBase}}assets/js/common.js" as="script">
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
padding: 1rem 0;
|
|
||||||
background: var(--card-bg) !important;
|
|
||||||
box-shadow: var(--nav-shadow);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: var(--card-shadow);
|
|
||||||
background-color: var(--card-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
color: var(--primary-color) !important;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode specific overrides */
|
|
||||||
[data-bs-theme="dark"] .navbar-light .navbar-toggler-icon {
|
|
||||||
filter: invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] .form-control,
|
|
||||||
[data-bs-theme="dark"] .form-select {
|
|
||||||
background-color: #374151;
|
|
||||||
color: #e5e7eb;
|
|
||||||
border-color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="dark"] .form-control:focus,
|
|
||||||
[data-bs-theme="dark"] .form-select:focus {
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Theme toggle button styles */
|
|
||||||
.theme-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
<script>
|
||||||
|
// Early theme detection to prevent FOUC
|
||||||
(function() {
|
(function() {
|
||||||
const savedTheme = localStorage.getItem('theme');
|
const savedTheme = localStorage.getItem('theme');
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
document.documentElement.setAttribute('data-bs-theme', savedTheme);
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'light');
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Set global URL base
|
||||||
|
window.urlBase = "{{.URLBase}}";
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="min-h-screen bg-base-200 flex flex-col">
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
<!-- Toast Container -->
|
||||||
|
<div class="toast-container fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
<!-- Toast messages will be created dynamically here -->
|
<!-- Toast messages will be created dynamically here -->
|
||||||
</div>
|
</div>
|
||||||
<nav class="navbar navbar-expand-lg navbar-light mb-4">
|
|
||||||
<div class="container">
|
<!-- Navigation -->
|
||||||
<a class="navbar-brand" href="/">
|
<header class="navbar bg-base-100 shadow-lg sticky top-0 z-40 backdrop-blur-sm">
|
||||||
<i class="bi bi-cloud-download me-2"></i>Decypharr
|
<div class="navbar-start">
|
||||||
</a>
|
<div class="dropdown">
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</button>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"/>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
</svg>
|
||||||
<ul class="navbar-nav me-auto">
|
</div>
|
||||||
<li class="nav-item">
|
<ul class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||||
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="{{.URLBase}}">
|
<li><a href="{{.URLBase}}" class="{{if eq .Page "index"}}active{{end}}">
|
||||||
<i class="bi bi-table me-1"></i>Torrents
|
<i class="bi bi-grid-3x3-gap text-primary"></i>Dashboard
|
||||||
</a>
|
</a></li>
|
||||||
</li>
|
<li><a href="{{.URLBase}}download" class="{{if eq .Page "download"}}active{{end}}">
|
||||||
<li class="nav-item">
|
<i class="bi bi-cloud-download text-secondary"></i>Download
|
||||||
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="{{.URLBase}}download">
|
</a></li>
|
||||||
<i class="bi bi-cloud-download me-1"></i>Download
|
<li><a href="{{.URLBase}}repair" class="{{if eq .Page "repair"}}active{{end}}">
|
||||||
</a>
|
<i class="bi bi-wrench-adjustable text-accent"></i>Repair
|
||||||
</li>
|
</a></li>
|
||||||
<li class="nav-item">
|
<li><a href="{{.URLBase}}config" class="{{if eq .Page "config"}}active{{end}}">
|
||||||
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="{{.URLBase}}repair">
|
<i class="bi bi-gear text-info"></i>Settings
|
||||||
<i class="bi bi-tools me-1"></i>Repair
|
</a></li>
|
||||||
</a>
|
<li><a href="{{.URLBase}}webdav" target="_blank">
|
||||||
</li>
|
<i class="bi bi-cloud text-success"></i>WebDAV
|
||||||
<li class="nav-item">
|
</a></li>
|
||||||
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="{{.URLBase}}config">
|
<li><a href="{{.URLBase}}logs" target="_blank">
|
||||||
<i class="bi bi-gear me-1"></i>Settings
|
<i class="bi bi-journal-text text-warning"></i>Logs
|
||||||
</a>
|
</a></li>
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{.URLBase}}webdav" target="_blank">
|
|
||||||
<i class="bi bi-cloud me-1"></i>WebDAV
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{.URLBase}}logs" target="_blank">
|
|
||||||
<i class="bi bi-journal me-1"></i>Logs
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<div class="d-flex align-items-center">
|
</div>
|
||||||
<div class="theme-toggle me-3" id="themeToggle" title="Toggle dark mode">
|
<a class="btn btn-ghost text-xl font-bold text-primary group" href="{{.URLBase}}">
|
||||||
<i class="bi bi-sun-fill" id="lightIcon"></i>
|
<!-- Logo -->
|
||||||
<i class="bi bi-moon-fill d-none" id="darkIcon"></i>
|
<img src="{{.URLBase}}assets/logo.svg" alt="Decypharr Logo" class="w-8 h-8 inline-block mr-2">
|
||||||
</div>
|
<span class="hidden sm:inline bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">Decypharr</span>
|
||||||
<a href="{{.URLBase}}debug/stats" class="me-2">
|
</a>
|
||||||
<i class="bi bi-bar-chart-line me-1"></i>Stats
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-center hidden lg:flex">
|
||||||
|
<ul class="menu menu-horizontal px-1 gap-1">
|
||||||
|
<li><a href="{{.URLBase}}" class="{{if eq .Page "index"}}active{{end}} tooltip tooltip-bottom" data-tip="Dashboard">
|
||||||
|
<i class="bi bi-grid-3x3-gap"></i>
|
||||||
|
<span class="hidden xl:inline">Dashboard</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{.URLBase}}download" class="{{if eq .Page "download"}}active{{end}} tooltip tooltip-bottom" data-tip="Add Downloads">
|
||||||
|
<i class="bi bi-cloud-download"></i>
|
||||||
|
<span class="hidden xl:inline">Download</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{.URLBase}}repair" class="{{if eq .Page "repair"}}active{{end}} tooltip tooltip-bottom" data-tip="Repair Media">
|
||||||
|
<i class="bi bi-wrench-adjustable"></i>
|
||||||
|
<span class="hidden xl:inline">Repair</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{.URLBase}}config" class="{{if eq .Page "config"}}active{{end}} tooltip tooltip-bottom" data-tip="Settings">
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
<span class="hidden xl:inline">Settings</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{.URLBase}}webdav" target="_blank" class="tooltip tooltip-bottom" data-tip="WebDAV Access">
|
||||||
|
<i class="bi bi-cloud"></i>
|
||||||
|
<span class="hidden xl:inline">WebDAV</span>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="{{.URLBase}}logs" target="_blank" class="tooltip tooltip-bottom" data-tip="System Logs">
|
||||||
|
<i class="bi bi-journal-text"></i>
|
||||||
|
<span class="hidden xl:inline">Logs</span>
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Theme Toggle -->
|
||||||
|
<div class="tooltip tooltip-left" data-tip="Toggle Theme">
|
||||||
|
<label class="swap swap-rotate btn btn-ghost btn-circle hover:bg-base-300 transition-colors">
|
||||||
|
<input type="checkbox" id="themeToggle" class="theme-controller" />
|
||||||
|
<!-- Sun icon for light mode -->
|
||||||
|
<i class="swap-off bi bi-sun text-lg text-warning"></i>
|
||||||
|
<!-- Moon icon for dark mode -->
|
||||||
|
<i class="swap-on bi bi-moon-stars text-lg text-info"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Link -->
|
||||||
|
<div class="tooltip tooltip-left" data-tip="System Statistics">
|
||||||
|
<a href="{{.URLBase}}debug/stats" class="btn btn-ghost btn-sm hover:bg-base-300 transition-colors">
|
||||||
|
<i class="bi bi-graph-up text-lg"></i>
|
||||||
|
<span class="hidden md:inline ml-1">Stats</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="badge bg-primary" id="version-badge">Loading...</span>
|
</div>
|
||||||
|
|
||||||
|
<!-- Version Badge -->
|
||||||
|
<div class="tooltip tooltip-left" data-tip="Current Version">
|
||||||
|
<div class="badge badge-primary font-mono text-xs hover:badge-primary-focus transition-colors cursor-pointer" id="version-badge">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
|
|
||||||
{{ if eq .Page "index" }}
|
<!-- Main Content -->
|
||||||
{{ template "index" . }}
|
<main class="flex-1 container mx-auto px-4 py-6">
|
||||||
{{ else if eq .Page "download" }}
|
{{ if eq .Page "index" }}
|
||||||
{{ template "download" . }}
|
{{ template "index" . }}
|
||||||
{{ else if eq .Page "repair" }}
|
{{ else if eq .Page "download" }}
|
||||||
{{ template "repair" . }}
|
{{ template "download" . }}
|
||||||
{{ else if eq .Page "config" }}
|
{{ else if eq .Page "repair" }}
|
||||||
{{ template "config" . }}
|
{{ template "repair" . }}
|
||||||
{{ else if eq .Page "login" }}
|
{{ else if eq .Page "config" }}
|
||||||
{{ template "login" . }}
|
{{ template "config" . }}
|
||||||
{{ else if eq .Page "register" }}
|
{{ else if eq .Page "login" }}
|
||||||
{{ template "register" . }}
|
{{ template "login" . }}
|
||||||
{{ else }}
|
{{ else if eq .Page "register" }}
|
||||||
{{ end }}
|
{{ template "register" . }}
|
||||||
|
{{ else }}
|
||||||
<footer class="mt-auto py-2 text-center border-top">
|
<div class="hero min-h-96">
|
||||||
<div class="container">
|
<div class="hero-content text-center">
|
||||||
<small class="text-muted">
|
<div class="max-w-md">
|
||||||
<a href="https://github.com/sirrobot01/decypharr" target="_blank" class="text-decoration-none me-3">
|
<h1 class="text-5xl font-bold text-error">404</h1>
|
||||||
<i class="bi bi-github me-1"></i>GitHub
|
<p class="py-6">Page not found. The page you're looking for doesn't exist.</p>
|
||||||
</a>
|
<a href="{{.URLBase}}" class="btn btn-primary">Go Home</a>
|
||||||
<a href="https://sirrobot01.github.io/decypharr" target="_blank" class="text-decoration-none">
|
</div>
|
||||||
<i class="bi bi-book me-1"></i>Documentation
|
</div>
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer footer-center p-6 bg-base-300 text-base-content border-t border-base-200">
|
||||||
|
<aside class="grid-flow-col gap-4">
|
||||||
|
<a href="https://github.com/sirrobot01/decypharr" target="_blank"
|
||||||
|
class="link link-hover flex items-center gap-2 hover:text-primary transition-colors">
|
||||||
|
<i class="bi bi-github text-lg"></i>
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://sirrobot01.github.io/decypharr" target="_blank"
|
||||||
|
class="link link-hover flex items-center gap-2 hover:text-primary transition-colors">
|
||||||
|
<i class="bi bi-book text-lg"></i>
|
||||||
|
<span>Documentation</span>
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||||
|
<script src="{{.URLBase}}assets/js/common.js"></script>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
<!-- Page-specific scripts -->
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
{{ if eq .Page "index" }}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
<script src="{{.URLBase}}assets/js/dashboard.js"></script>
|
||||||
<script>
|
{{ else if eq .Page "download" }}
|
||||||
|
<script src="{{.URLBase}}assets/js/download.js"></script>
|
||||||
window.urlBase = "{{.URLBase}}";
|
{{ else if eq .Page "repair" }}
|
||||||
|
<script src="{{.URLBase}}assets/js/repair.js"></script>
|
||||||
function joinURL(base, path) {
|
{{ else if eq .Page "config" }}
|
||||||
if (!base.endsWith('/')) {
|
<script src="{{.URLBase}}assets/js/config.js"></script>
|
||||||
base += '/';
|
{{ end }}
|
||||||
}
|
|
||||||
if (path.startsWith('/')) {
|
|
||||||
path = path.substring(1);
|
|
||||||
}
|
|
||||||
return base + path;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetcher(endpoint, options = {}) {
|
|
||||||
// Use the global urlBase or default to empty string
|
|
||||||
let baseUrl = window.urlBase || '';
|
|
||||||
|
|
||||||
let url = joinURL(baseUrl, endpoint);
|
|
||||||
|
|
||||||
// Return the regular fetcher with the complete URL
|
|
||||||
return fetch(url, options);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Create a toast message
|
|
||||||
* @param {string} message - The message to display
|
|
||||||
* @param {string} [type='success'] - The type of toast (success, warning, error)
|
|
||||||
*/
|
|
||||||
const createToast = (message, type = 'success') => {
|
|
||||||
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
|
|
||||||
|
|
||||||
const toastTimeouts = {
|
|
||||||
success: 5000,
|
|
||||||
warning: 10000,
|
|
||||||
error: 15000
|
|
||||||
};
|
|
||||||
|
|
||||||
const toastContainer = document.querySelector('.toast-container');
|
|
||||||
const toastId = `toast-${Date.now()}`;
|
|
||||||
|
|
||||||
const toastHtml = `
|
|
||||||
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div class="toast-header ${type === 'error' ? 'bg-danger text-white' : type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white'}">
|
|
||||||
<strong class="me-auto">
|
|
||||||
${type === 'error' ? 'Error' : type === 'warning' ? 'Warning' : 'Success'}
|
|
||||||
</strong>
|
|
||||||
<button type="button" class="btn-close ${type === 'warning' ? '' : 'btn-close-white'}" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="toast-body">
|
|
||||||
${message.replace(/\n/g, '<br>')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
|
||||||
|
|
||||||
const toastElement = document.getElementById(toastId);
|
|
||||||
const toast = new bootstrap.Toast(toastElement, {
|
|
||||||
autohide: true,
|
|
||||||
delay: toastTimeouts[type]
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.show();
|
|
||||||
|
|
||||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
|
||||||
toastElement.remove();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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');
|
|
||||||
const darkIcon = document.getElementById('darkIcon');
|
|
||||||
const htmlElement = document.documentElement;
|
|
||||||
|
|
||||||
// Function to set the theme
|
|
||||||
function setTheme(theme) {
|
|
||||||
htmlElement.setAttribute('data-bs-theme', theme);
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
|
|
||||||
if (theme === 'dark') {
|
|
||||||
lightIcon.classList.add('d-none');
|
|
||||||
darkIcon.classList.remove('d-none');
|
|
||||||
} else {
|
|
||||||
lightIcon.classList.remove('d-none');
|
|
||||||
darkIcon.classList.add('d-none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for saved theme preference or use system preference
|
|
||||||
const savedTheme = localStorage.getItem('theme');
|
|
||||||
|
|
||||||
if (savedTheme) {
|
|
||||||
setTheme(savedTheme);
|
|
||||||
} else {
|
|
||||||
// Check for system preference
|
|
||||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
setTheme('dark');
|
|
||||||
} else {
|
|
||||||
setTheme('light');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle theme when button is clicked
|
|
||||||
themeToggle.addEventListener('click', () => {
|
|
||||||
const currentTheme = htmlElement.getAttribute('data-bs-theme');
|
|
||||||
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
if (window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
|
||||||
if (!localStorage.getItem('theme')) {
|
|
||||||
setTheme(e.matches ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
fetcher('/version')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const versionBadge = document.getElementById('version-badge');
|
|
||||||
|
|
||||||
// Add url to version badge
|
|
||||||
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/decypharr/releases/tag/${data.version}" target="_blank" class="text-white">${data.channel}-${data.version}</a>`;
|
|
||||||
|
|
||||||
|
|
||||||
if (data.channel === 'beta') {
|
|
||||||
versionBadge.classList.add('beta');
|
|
||||||
} else if (data.channel === 'nightly') {
|
|
||||||
versionBadge.classList.add('nightly');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error fetching version:', error);
|
|
||||||
document.getElementById('version-badge').textContent = 'Unknown';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
{{ define "login" }}
|
{{ define "login" }}
|
||||||
<div class="container mt-5">
|
<div class="flex min-h-screen items-center justify-center bg-base-200">
|
||||||
<div class="row justify-content-center">
|
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||||
<div class="col-md-6 col-lg-4">
|
<div class="card-body">
|
||||||
<div class="card">
|
<h2 class="card-title justify-center mb-6">Login</h2>
|
||||||
<div class="card-header">
|
<form id="loginForm" class="space-y-4">
|
||||||
<h4 class="mb-0 text-center">Login</h4>
|
<div class="form-control">
|
||||||
|
<label class="label" for="username">
|
||||||
|
<span class="label-text">Username</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input input-bordered w-full" id="username" name="username" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="form-control">
|
||||||
<form id="loginForm">
|
<label class="label" for="password">
|
||||||
<div class="mb-3">
|
<span class="label-text">Password</span>
|
||||||
<label for="username" class="form-label">Username</label>
|
</label>
|
||||||
<input type="text" class="form-control" id="username" name="username" required>
|
<input type="password" class="input input-bordered w-full" id="password" name="password" required>
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Password</label>
|
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<div class="d-grid">
|
|
||||||
<button type="submit" class="btn btn-primary">Login</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-control mt-6">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,6 +27,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
let loginBtn = document.querySelector('#loginForm button[type="submit"]');
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
username: document.getElementById('username').value,
|
username: document.getElementById('username').value,
|
||||||
@@ -45,13 +44,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
window.location.href = '/';
|
window.decypharrUtils.createToast('Login successful! Redirecting...', 'success');
|
||||||
|
|
||||||
|
// Redirect after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = window.urlBase || '/';
|
||||||
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
createToast('Invalid credentials', 'error');
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || 'Invalid credentials');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
createToast('Login failed', 'error');
|
window.decypharrUtils.createToast(error.message || 'Login failed. Please try again.', 'error');
|
||||||
|
} finally {
|
||||||
|
window.decypharrUtils.setButtonLoading(loginBtn, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
{{ define "register" }}
|
{{ define "register" }}
|
||||||
<div class="container mt-5">
|
<div class="flex min-h-screen items-center justify-center bg-base-200">
|
||||||
<div class="row justify-content-center">
|
<div class="card w-full max-w-md bg-base-100 shadow-xl">
|
||||||
<div class="col-md-6 col-lg-4">
|
<div class="card-body">
|
||||||
<div class="card">
|
<h2 class="card-title justify-center mb-6">First Time Auth Setup</h2>
|
||||||
<div class="card-header">
|
<form id="authForm" class="space-y-4">
|
||||||
<h4 class="mb-0 text-center">First Time Auth Setup</h4>
|
<div class="form-control">
|
||||||
|
<label class="label" for="username">
|
||||||
|
<span class="label-text">Username</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input input-bordered w-full" id="username" name="username" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="form-control">
|
||||||
<form id="authForm">
|
<label class="label" for="password">
|
||||||
<div class="mb-3">
|
<span class="label-text">Password</span>
|
||||||
<label for="username" class="form-label">Username</label>
|
</label>
|
||||||
<input type="text" class="form-control" id="username" name="username" required>
|
<input type="password" class="input input-bordered w-full" id="password" name="password" required>
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="password" class="form-label">Password</label>
|
|
||||||
<input type="password" class="form-control" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
|
||||||
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
|
||||||
</div>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<button type="submit" class="btn btn-primary mb-2">Save</button>
|
|
||||||
<button type="button" id="skipAuthBtn" class="btn btn-secondary">Skip</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-control">
|
||||||
|
<label class="label" for="confirmPassword">
|
||||||
|
<span class="label-text">Confirm Password</span>
|
||||||
|
</label>
|
||||||
|
<input type="password" class="input input-bordered w-full" id="confirmPassword" name="confirmPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mt-6 space-y-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Save</button>
|
||||||
|
<button type="button" id="skipAuthBtn" class="btn btn-secondary w-full">Skip</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type RepairRequest struct {
|
|||||||
AutoProcess bool `json:"autoProcess"`
|
AutoProcess bool `json:"autoProcess"`
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed templates/*
|
//go:embed templates/* assets/*
|
||||||
var content embed.FS
|
var content embed.FS
|
||||||
|
|
||||||
type Web struct {
|
type Web struct {
|
||||||
|
|||||||