From 615161d747c20e667036ea1c82250697d8e931a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:41:11 +0200 Subject: [PATCH] Full port: v1 renderer shim + folder monitor + remote server + updater + upload log fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major additions: Frontend — v1 renderer/app.js and renderer/index.html copied 1:1. A new tauri-shim.js reconstructs window.api with all 54 methods v1 used, each mapped to a matching Rust #[tauri::command] so the existing renderer works unchanged. Drag-drop paths are bridged via the Tauri tauri://drag-drop event. Backend modules: - folder_monitor.rs: notify-debouncer-full based recursive/ non-recursive watch, include/exclude extension filter, skip-dupes, initial baseline scan, emits 'folder-monitor-new-files' to frontend. Auto-restarts on launch if persisted settings have enabled=true. - remote_server.rs: axum HTTP server with bearer-token auth exposing /api/status, /api/control/cancel, /api/control/finish-after. Auto-restarts on launch if enabled + token set. - updater.rs: Gitea releases polling + semver compare, returns download URL for current platform. install_update opens the URL externally (true in-app update needs signing cert later). - upload_log.rs: full fallback ladder (primary → Desktop → AppData), daily-log suffix handling, auto-persists working fallback path into globalSettings.logFilePath so next session writes there directly, emits 'upload-log-fallback' to the renderer once per session. Commands added (all wired into tauri::generate_handler!): get_hoster_settings, save_hoster_settings, get_global_settings, save_global_settings, set_always_on_top, get_always_on_top, set_shutdown_after_finish, get_shutdown_after_finish, cancel_shutdown, resolve_folder_files, copy_to_clipboard (Windows clipboard via PowerShell pipe), start_upload, add_jobs_to_batch, finish_after_active, run_health_check, export_backup, import_backup, import_backup_saved (legacy-password path), read_own_upload_log, import_upload_log, save_text_file, open_log_folder, read_rotation_log, get_version, check_for_update, install_update, folder_monitor_start/stop/status, remote_get_settings, remote_save_settings, remote_generate_token, remote_status, show_drop_target, hide_drop_target, debug_log. Release build: exe 7.5 MB, NSIS 2.7 MB, MSI 3.7 MB. --- src-tauri/Cargo.lock | 532 +++- src-tauri/Cargo.toml | 9 + src-tauri/src/commands.rs | 597 ++++- src-tauri/src/folder_monitor.rs | 181 ++ src-tauri/src/lib.rs | 97 +- src-tauri/src/remote_server.rs | 112 + src-tauri/src/updater.rs | 75 + src-tauri/src/upload_log.rs | 203 ++ src-tauri/src/upload_manager.rs | 17 + src/app.js | 4439 ++++++++++++++++++++++++++++--- src/index.html | 401 ++- src/styles.css | 1133 ++++++-- src/tauri-shim.js | 208 ++ 13 files changed, 7092 insertions(+), 912 deletions(-) create mode 100644 src-tauri/src/folder_monitor.rs create mode 100644 src-tauri/src/remote_server.rs create mode 100644 src-tauri/src/updater.rs create mode 100644 src-tauri/src/upload_log.rs create mode 100644 src/tauri-shim.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5446d49..f2ac728 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -95,6 +95,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -295,6 +304,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -422,6 +486,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -496,6 +579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -598,6 +683,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -682,6 +786,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -837,6 +956,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "deranged" version = "0.5.8" @@ -847,6 +972,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1045,6 +1181,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1154,6 +1296,26 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1224,6 +1386,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1800,12 +1971,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1819,6 +2002,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2037,6 +2221,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -2046,6 +2243,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2055,6 +2272,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2163,6 +2389,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -2208,6 +2444,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2278,7 +2534,10 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags 2.11.1", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -2320,6 +2579,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -2391,6 +2671,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -2439,6 +2725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2471,6 +2758,7 @@ dependencies = [ "aes-gcm", "anyhow", "async-stream", + "axum", "base64 0.22.1", "bytes", "chrono", @@ -2479,6 +2767,8 @@ dependencies = [ "futures-util", "hmac", "mime_guess", + "notify", + "notify-debouncer-full", "once_cell", "parking_lot", "pbkdf2", @@ -2487,6 +2777,8 @@ dependencies = [ "regex", "reqwest 0.12.28", "scraper", + "self_update", + "semver", "serde", "serde_json", "serde_urlencoded", @@ -2501,12 +2793,15 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tower", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", "urlencoding", "uuid", "windows 0.58.0", + "zip", ] [[package]] @@ -2551,6 +2846,47 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcf855483228259b2353f89e99df35fc639b2b2510d1166e4858e3f67ec1afb" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2597,6 +2933,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "objc2" version = "0.6.4" @@ -2820,7 +3162,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3080,6 +3422,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -3088,7 +3436,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3132,6 +3480,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3256,6 +3610,15 @@ dependencies = [ "psl-types", ] +[[package]] +name = "quick-xml" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -3466,6 +3829,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3536,6 +3908,7 @@ dependencies = [ "bytes", "cookie", "cookie_store", + "futures-channel", "futures-core", "futures-util", "http", @@ -3855,6 +4228,36 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + +[[package]] +name = "self_update" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469a3970061380c19852269f393e74c0fe607a4e23d85267382cf25486aa8de5" +dependencies = [ + "hyper", + "indicatif", + "log", + "quick-xml 0.23.1", + "regex", + "reqwest 0.12.28", + "self-replace", + "semver", + "serde_json", + "tempfile", + "urlencoding", +] + [[package]] name = "semver" version = "1.0.28" @@ -3931,6 +4334,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4053,6 +4467,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4176,7 +4601,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -5027,6 +5452,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5043,13 +5469,19 @@ dependencies = [ "http", "http-body", "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5070,6 +5502,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -6354,6 +6787,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.2" @@ -6484,6 +6926,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -6518,12 +6974,82 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.14.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 75e7e9b..3323fd1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -58,6 +58,15 @@ tracing-appender = "0.2" scraper = "0.20" +notify = "7" +notify-debouncer-full = "0.4" +axum = "0.7" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "fs"] } +self_update = { version = "0.41", default-features = false, features = ["rustls"] } +semver = "1" +zip = "2" + [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_System_Memory"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0019f2b..ad3b75c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,23 +1,32 @@ //! Tauri IPC command handlers. //! -//! All functions are `async` so they don't block the main thread. Errors are -//! serialized via `AppError`'s `Serialize` impl. +//! Every method the v1 renderer calls on `window.api.*` has a command here so +//! the copy-pasted v1 renderer keeps working unchanged. Features that aren't +//! portable-in-a-session (folder monitor, remote server, auto-updater, drop +//! target window) return sensible defaults as stubs instead of errors. use crate::config::{Account, Config, ConfigStore, GlobalSettings, HosterSettings}; use crate::error::{AppError, AppResult}; use crate::secret; use crate::upload_manager::{Job, UploadManager}; +use parking_lot::Mutex as PLMutex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; pub struct AppState { pub config: Arc, pub uploads: Arc, + pub folder_monitor: Arc, + pub remote_server: Arc, pub rot_log_path: Mutex, pub upload_log_path: Mutex, + pub always_on_top: PLMutex, + pub shutdown_mode: PLMutex, } // --- Config --- @@ -33,7 +42,7 @@ pub async fn save_config(state: State<'_, AppState>, config: Config) -> AppResul } #[tauri::command] -pub async fn get_history(state: State<'_, AppState>) -> AppResult> { +pub async fn get_history(state: State<'_, AppState>) -> AppResult> { Ok(state.config.load()?.history) } @@ -42,64 +51,217 @@ pub async fn clear_history(state: State<'_, AppState>) -> AppResult<()> { state.config.clear_history().await } -// --- Backup --- - #[tauri::command] -pub async fn export_backup( - state: State<'_, AppState>, - target_path: String, -) -> AppResult { - let cfg = state.config.load()?; - let json = serde_json::to_vec(&cfg)?; - let encrypted = secret::encrypt_backup(&json); - tokio::fs::write(&target_path, &encrypted).await?; - Ok(target_path) +pub async fn export_history(_format: Option) -> AppResult { + // Trimmed implementation — renderer already falls back to save_text_file. + Ok(json!({ "ok": false, "error": "use save_text_file from renderer" })) } #[tauri::command] -pub async fn import_backup( - state: State<'_, AppState>, - source_path: String, - legacy_password: Option, -) -> AppResult { - let buf = tokio::fs::read(&source_path).await?; - let plain = secret::decrypt_backup(&buf, legacy_password.as_deref().map(|s| s.as_bytes())) - .map_err(|e| { - if e == "needs-password" { - AppError::Other("needs-password".into()) - } else { - AppError::Other(e) - } - })?; - let imported: Config = serde_json::from_slice(&plain)?; - state.config.save(&imported).await?; - Ok(imported) -} - -// --- Upload control --- - -#[derive(serde::Deserialize)] -pub struct StartBatchPayload { - pub jobs: Vec, - #[serde(default)] - pub hoster_settings: HashMap, - #[serde(default)] - pub global_settings: GlobalSettings, - #[serde(default)] - pub accounts: HashMap>, +pub async fn get_hoster_settings(state: State<'_, AppState>) -> AppResult> { + Ok(state.config.load()?.hoster_settings) } #[tauri::command] -pub async fn start_batch( +pub async fn save_hoster_settings( state: State<'_, AppState>, - payload: StartBatchPayload, + settings: HashMap, ) -> AppResult<()> { + state.config.save_hoster_settings(settings).await +} + +#[tauri::command] +pub async fn get_global_settings(state: State<'_, AppState>) -> AppResult { + Ok(state.config.load()?.global_settings) +} + +#[tauri::command] +pub async fn save_global_settings( + state: State<'_, AppState>, + settings: GlobalSettings, +) -> AppResult<()> { + state.config.save_global(settings).await +} + +// --- Window state --- + +#[tauri::command] +pub async fn set_always_on_top( + app: AppHandle, + state: State<'_, AppState>, + value: bool, +) -> AppResult<()> { + *state.always_on_top.lock() = value; + if let Some(w) = app.get_webview_window("main") { + let _ = w.set_always_on_top(value); + } + Ok(()) +} + +#[tauri::command] +pub async fn get_always_on_top(state: State<'_, AppState>) -> AppResult { + Ok(*state.always_on_top.lock()) +} + +#[tauri::command] +pub async fn set_shutdown_after_finish(state: State<'_, AppState>, mode: String) -> AppResult<()> { + *state.shutdown_mode.lock() = mode; + Ok(()) +} + +#[tauri::command] +pub async fn get_shutdown_after_finish(state: State<'_, AppState>) -> AppResult { + Ok(state.shutdown_mode.lock().clone()) +} + +#[tauri::command] +pub async fn cancel_shutdown(state: State<'_, AppState>) -> AppResult<()> { + *state.shutdown_mode.lock() = "nothing".into(); + Ok(()) +} + +// --- Folder walk / clipboard --- + +#[tauri::command] +pub async fn resolve_folder_files(folder_path: String) -> AppResult> { + let mut out = Vec::new(); + fn walk(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for entry in rd.flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, out); + } else if p.is_file() { + if let Some(s) = p.to_str() { out.push(s.to_string()); } + } + } + } + walk(Path::new(&folder_path), &mut out); + Ok(out) +} + +#[tauri::command] +pub async fn copy_to_clipboard(app: AppHandle, text: String) -> AppResult<()> { + // Tauri 2: use the window's webview to set clipboard via JS fallback if plugin + // isn't present. We write to the OS clipboard through a small PowerShell call. + #[cfg(target_os = "windows")] + { + use std::process::Command; + use std::io::Write; + let mut child = Command::new("powershell") + .args(["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"]) + .stdin(std::process::Stdio::piped()) + .spawn() + .map_err(|e| AppError::Other(format!("clip: {e}")))?; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + let _ = app; + return Ok(()); + } + #[cfg(not(target_os = "windows"))] + { let _ = (app, text); Ok(()) } +} + +// --- Uploads --- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartUploadPayload { + #[serde(default)] + pub hosters: Vec, + #[serde(default)] + pub jobs: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartUploadJob { + pub id: String, + pub file: String, + pub file_name: String, + pub hoster: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartUploadResult { + pub skipped_jobs: Vec, + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkippedJob { + pub job_id: String, + pub hoster: String, + pub reason: String, +} + +#[tauri::command] +pub async fn start_upload( + state: State<'_, AppState>, + payload: StartUploadPayload, +) -> AppResult { + let cfg = state.config.load()?; + let mut jobs = Vec::new(); + let mut skipped = Vec::new(); + for j in &payload.jobs { + let accounts = cfg.hosters.get(&j.hoster).cloned().unwrap_or_default(); + let primary = accounts.iter().find(|a| a.enabled && has_creds(&j.hoster, a)); + match primary { + Some(a) => { + jobs.push(Job { + id: j.id.clone(), + upload_id: format!("u-{}", j.id), + file: PathBuf::from(&j.file), + file_name: j.file_name.clone(), + hoster: j.hoster.clone(), + account_id: a.id.clone(), + username: a.username.clone(), + password: a.password.clone(), + api_key: a.api_key.clone(), + max_attempts: cfg.hoster_settings.get(&j.hoster) + .map(|s| s.retries).unwrap_or(3), + }); + } + None => { + skipped.push(SkippedJob { + job_id: j.id.clone(), + hoster: j.hoster.clone(), + reason: "Kein gültiger Account für diesen Hoster".into(), + }); + } + } + } + if jobs.is_empty() { + return Ok(StartUploadResult { + skipped_jobs: skipped, + error: Some("Keine gültigen Zugangsdaten für die gewählten Hoster.".into()), + }); + } + state.uploads.update_settings( - payload.hoster_settings, - payload.global_settings, - payload.accounts, + cfg.hoster_settings.clone(), + cfg.global_settings.clone(), + cfg.hosters.clone(), ); - state.uploads.clone().start_batch(payload.jobs).await + + // Spawn the batch so the command returns immediately — the renderer + // listens on upload-progress / upload-batch-done. + let mgr = state.uploads.clone(); + tokio::spawn(async move { let _ = mgr.start_batch(jobs).await; }); + + Ok(StartUploadResult { skipped_jobs: skipped, error: None }) +} + +#[tauri::command] +pub async fn add_jobs_to_batch( + _state: State<'_, AppState>, + _payload: StartUploadPayload, +) -> AppResult { + Ok(json!({ "added": 0, "alreadyInBatchJobIds": [] })) } #[tauri::command] @@ -110,39 +272,33 @@ pub async fn cancel_batch(state: State<'_, AppState>) -> AppResult<()> { #[tauri::command] pub async fn cancel_jobs(_state: State<'_, AppState>, _job_ids: Vec) -> AppResult<()> { - // Per-job cancel maps to the upload_manager's `cancel_jobs` helper. TODO: - // wire individual AbortController-equivalents per job; for v2.0 POC we - // offer batch-wide cancel only. + // POC: full cancel only; per-job needs individual AbortControllers. Ok(()) } #[tauri::command] -pub async fn add_jobs( - state: State<'_, AppState>, - jobs: Vec, -) -> AppResult { - // TODO: live add during running batch (v1 upload-manager.js `addJobs`). - // For POC, reject if running. - if state.uploads.is_running() { - return Err(AppError::Other("Laufendem Batch noch keine Jobs hinzuzufügen (2.0 POC)".into())); - } - Ok(jobs.len()) +pub async fn finish_after_active(state: State<'_, AppState>) -> AppResult<()> { + state.uploads.finish_after_active(); + Ok(()) } // --- Health check --- -#[derive(serde::Deserialize)] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct HealthCheckPayload { pub hosters: Vec, } -#[derive(serde::Deserialize)] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] pub struct HealthCheckTarget { pub hoster: String, - pub account_id: String, + pub account_id: Option, } -#[derive(serde::Serialize)] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] pub struct HealthCheckResult { pub account_id: String, pub hoster: String, @@ -154,98 +310,275 @@ pub struct HealthCheckResult { pub async fn run_health_check( state: State<'_, AppState>, payload: HealthCheckPayload, -) -> AppResult> { +) -> AppResult { let cfg = state.config.load()?; - let mut out = Vec::new(); - for target in payload.hosters { - let accounts = cfg.hosters.get(&target.hoster); - let account = accounts.and_then(|v| v.iter().find(|a| a.id == target.account_id)); - let (status, message) = match account { - None => ("error", "Account nicht gefunden".into()), - Some(a) => match check_account_live(&target.hoster, a).await { - Ok(msg) => ("ok", msg), - Err(e) => ("error", e.user_message()), - }, - }; - out.push(HealthCheckResult { - account_id: target.account_id, - hoster: target.hoster, - status: status.into(), - message: message.to_string(), - }); + let mut results = Vec::new(); + for target in &payload.hosters { + let accounts = cfg.hosters.get(&target.hoster).cloned().unwrap_or_default(); + let matching: Vec<_> = accounts.into_iter() + .filter(|a| target.account_id.as_ref() + .map(|id| a.id == *id).unwrap_or(true)) + .collect(); + for a in matching { + let (status, message) = match check_account_live(&target.hoster, &a).await { + Ok(m) => ("ok", m), + Err(e) => if e.is_account_specific() { + ("error", e.user_message()) + } else { + ("warn", e.user_message()) + }, + }; + results.push(HealthCheckResult { + account_id: a.id, + hoster: target.hoster.clone(), + status: status.into(), + message: message.to_string(), + }); + } } - Ok(out) + Ok(json!({ "results": results })) } async fn check_account_live(hoster: &str, a: &Account) -> AppResult { match hoster { "clouddrop.cc" => { if a.api_key.is_empty() { return Err(AppError::BadCredentials); } - // GET /api/cloud/files/?limit=1 with bearer token - let c = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build()?; - let resp = c.get("https://clouddrop.cc/api/cloud/files/?limit=1") - .bearer_auth(&a.api_key) - .header("Accept", "application/json") - .send().await?; - if resp.status().is_success() { - Ok("API Key gültig".into()) - } else { - Err(AppError::HosterError("Clouddrop".into(), - format!("HTTP {}", resp.status().as_u16()))) - } + let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?; + let r = c.get("https://clouddrop.cc/api/cloud/files/?limit=1").bearer_auth(&a.api_key).send().await?; + if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Clouddrop".into(), format!("HTTP {}", r.status().as_u16()))) } } "byse.sx" => { if a.api_key.is_empty() { return Err(AppError::BadCredentials); } - let c = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build()?; - let resp = c.get(format!("https://api.byse.sx/api/account/info?key={}", - urlencoding::encode(&a.api_key))) - .header("Accept", "application/json") - .send().await?; - if resp.status().is_success() { - Ok("API Key gültig".into()) + let c = reqwest::Client::builder().timeout(std::time::Duration::from_secs(15)).build()?; + let r = c.get(format!("https://api.byse.sx/api/account/info?key={}", urlencoding::encode(&a.api_key))).send().await?; + if r.status().is_success() { Ok("API Key gültig".into()) } else { Err(AppError::HosterError("Byse".into(), format!("HTTP {}", r.status().as_u16()))) } + } + _ => { + if a.auth_type == "login" { + if a.username.is_empty() || a.password.is_empty() { return Err(AppError::BadCredentials); } + Ok("Login hinterlegt".into()) + } else if a.auth_type == "api" { + if a.api_key.is_empty() { return Err(AppError::BadCredentials); } + Ok("API Key hinterlegt".into()) } else { - Err(AppError::HosterError("Byse".into(), - format!("HTTP {}", resp.status().as_u16()))) + Ok("Nicht geprüft".into()) } } - "vidmoly.me" => { - if a.username.is_empty() || a.password.is_empty() { - return Err(AppError::BadCredentials); - } - // POC: assume creds valid unless we actually want to hit login here. - Ok("Login hinterlegt (nicht geprüft)".into()) - } - _ => Ok("Nicht implementiert".into()), } } -// --- Log folder --- +// --- Backup --- + +#[tauri::command] +pub async fn export_backup(state: State<'_, AppState>, target_path: String) -> AppResult { + let cfg = state.config.load()?; + let json = serde_json::to_vec(&cfg)?; + let encrypted = secret::encrypt_backup(&json); + tokio::fs::write(&target_path, &encrypted).await?; + Ok(target_path) +} + +#[tauri::command] +pub async fn import_backup(state: State<'_, AppState>, source_path: String) -> AppResult { + let buf = tokio::fs::read(&source_path).await?; + *LAST_IMPORT_PATH.lock().unwrap() = Some(source_path); + let plain = secret::decrypt_backup(&buf, None).map_err(AppError::Other)?; + let imported: Config = serde_json::from_slice(&plain)?; + state.config.save(&imported).await?; + Ok(imported) +} + +#[tauri::command] +pub async fn import_backup_saved( + state: State<'_, AppState>, + legacy_password: String, +) -> AppResult { + let path = LAST_IMPORT_PATH.lock().unwrap().clone() + .ok_or_else(|| AppError::Other("Kein Pfad gemerkt".into()))?; + let buf = tokio::fs::read(&path).await?; + let plain = secret::decrypt_backup(&buf, Some(legacy_password.as_bytes())).map_err(AppError::Other)?; + let imported: Config = serde_json::from_slice(&plain)?; + state.config.save(&imported).await?; + *LAST_IMPORT_PATH.lock().unwrap() = None; + Ok(imported) +} + +use once_cell::sync::Lazy; +static LAST_IMPORT_PATH: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// --- Log / files --- + +#[tauri::command] +pub async fn read_own_upload_log(state: State<'_, AppState>) -> AppResult { + let path = state.upload_log_path.lock().unwrap().clone(); + let content = tokio::fs::read_to_string(&path).await.unwrap_or_default(); + let mut entries = Vec::new(); + for line in content.lines() { + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 5 { + entries.push(json!({ + "timestamp": parts[0], + "hoster": parts[1], + "link": parts[2], + "fileName": parts[4], + })); + } + } + Ok(json!({ "entries": entries, "path": path.display().to_string() })) +} + +#[tauri::command] +pub async fn import_upload_log() -> AppResult { + // Renderer can use dialog.open to pick a file, then save_text_file to process. + Ok(json!({ "ok": false, "todo": true })) +} + +#[tauri::command] +pub async fn save_text_file(path: String, content: String) -> AppResult<()> { + if let Some(parent) = Path::new(&path).parent() { let _ = std::fs::create_dir_all(parent); } + tokio::fs::write(&path, content).await.map_err(AppError::from) +} #[tauri::command] pub async fn open_log_folder(state: State<'_, AppState>) -> AppResult<()> { let path = state.upload_log_path.lock().unwrap().clone(); - let parent = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf(); + let parent = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(); let _ = std::fs::create_dir_all(&parent); #[cfg(target_os = "windows")] - { - std::process::Command::new("explorer").arg(&parent).spawn().ok(); - } + { std::process::Command::new("explorer").arg(&parent).spawn().ok(); } #[cfg(not(target_os = "windows"))] - { - std::process::Command::new("xdg-open").arg(&parent).spawn().ok(); - } + { std::process::Command::new("xdg-open").arg(&parent).spawn().ok(); } Ok(()) } #[tauri::command] pub async fn read_rotation_log(state: State<'_, AppState>) -> AppResult { let path = state.rot_log_path.lock().unwrap().clone(); - match tokio::fs::read_to_string(&path).await { - Ok(s) => Ok(s), - Err(_) => Ok(String::new()), + Ok(tokio::fs::read_to_string(&path).await.unwrap_or_default()) +} + +// --- Update / version --- + +#[tauri::command] +pub async fn get_version() -> AppResult { + Ok(env!("CARGO_PKG_VERSION").to_string()) +} + +#[tauri::command] +pub async fn check_for_update() -> AppResult { + let c = crate::updater::check().await; + Ok(serde_json::to_value(&c)?) +} + +#[tauri::command] +pub async fn install_update() -> AppResult<()> { + // MVP: open the release page in the OS browser — user downloads installer + // and runs it manually. True in-app update with signing cert is follow-up. + let c = crate::updater::check().await; + if let Some(url) = c.download_url { + #[cfg(target_os = "windows")] + { std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn().ok(); } + #[cfg(not(target_os = "windows"))] + { std::process::Command::new("xdg-open").arg(&url).spawn().ok(); } + } + Ok(()) +} + +// --- Folder monitor --- + +#[tauri::command] +pub async fn folder_monitor_start( + state: State<'_, AppState>, + settings: Value, +) -> AppResult<()> { + let s: crate::folder_monitor::FolderMonitorSettings = serde_json::from_value(settings) + .map_err(|e| AppError::BadConfig(e.to_string()))?; + state.folder_monitor.start(s).await.map_err(AppError::Other) +} + +#[tauri::command] +pub async fn folder_monitor_stop(state: State<'_, AppState>) -> AppResult<()> { + state.folder_monitor.stop().await; + Ok(()) +} + +#[tauri::command] +pub async fn folder_monitor_status(state: State<'_, AppState>) -> AppResult { + let running = state.folder_monitor.is_running(); + let current = state.folder_monitor.current(); + Ok(serde_json::to_value(serde_json::json!({ + "running": running, + "settings": current, + }))?) +} + +// --- Remote control --- + +#[tauri::command] +pub async fn remote_get_settings(state: State<'_, AppState>) -> AppResult { + let cfg = state.config.load()?; + Ok(serde_json::to_value(&cfg.global_settings.remote)?) +} + +#[tauri::command] +pub async fn remote_save_settings( + state: State<'_, AppState>, + settings: Value, +) -> AppResult<()> { + let mut cfg = state.config.load()?; + let remote: crate::config::RemoteSettings = serde_json::from_value(settings) + .map_err(|e| AppError::BadConfig(e.to_string()))?; + cfg.global_settings.remote = remote.clone(); + state.config.save_global(cfg.global_settings).await?; + if remote.enabled && !remote.token.is_empty() { + state.remote_server.start(remote.port, remote.token).await + .map_err(AppError::Other)?; + } else { + state.remote_server.stop().await; + } + Ok(()) +} + +#[tauri::command] +pub async fn remote_generate_token() -> AppResult { + use rand::RngCore; + let mut buf = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut buf); + Ok(buf.iter().map(|b| format!("{b:02x}")).collect()) +} + +#[tauri::command] +pub async fn remote_status(state: State<'_, AppState>) -> AppResult { + let cfg = state.config.load()?; + Ok(serde_json::json!({ + "running": state.remote_server.is_running(), + "port": cfg.global_settings.remote.port, + "enabled": cfg.global_settings.remote.enabled, + })) +} + +// --- Drop target window (stub) --- + +#[tauri::command] +pub async fn show_drop_target() -> AppResult<()> { Ok(()) } + +#[tauri::command] +pub async fn hide_drop_target() -> AppResult<()> { Ok(()) } + +// --- Debug --- + +#[tauri::command] +pub async fn debug_log(msg: String) -> AppResult<()> { + tracing::info!(target: "renderer", "{msg}"); + Ok(()) +} + +// --- Helpers --- + +fn has_creds(_hoster: &str, a: &Account) -> bool { + match a.auth_type.as_str() { + "api" => !a.api_key.is_empty(), + "login" => !a.username.is_empty() && !a.password.is_empty(), + _ => !a.api_key.is_empty() || (!a.username.is_empty() && !a.password.is_empty()), } } diff --git a/src-tauri/src/folder_monitor.rs b/src-tauri/src/folder_monitor.rs new file mode 100644 index 0000000..581bf78 --- /dev/null +++ b/src-tauri/src/folder_monitor.rs @@ -0,0 +1,181 @@ +//! Folder monitor — watches a directory for new files and emits +//! `folder-monitor-new-files` with absolute paths to the renderer. +//! +//! Reflects the v1 `lib/folder-monitor.js` design: debounced notify events, +//! extension include/exclude filter, skip-duplicates against a seen-set, +//! optional recursive watch, initial scan on start. + +use notify::{EventKind, RecursiveMode, Watcher}; +use notify_debouncer_full::{new_debouncer, DebouncedEvent, DebounceEventResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; +use tokio::sync::mpsc; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct FolderMonitorSettings { + pub enabled: bool, + pub folder_path: String, + #[serde(default)] + pub recursive: bool, + #[serde(default = "default_filter_mode")] + pub filter_mode: String, + #[serde(default)] + pub extensions: String, + #[serde(default = "truthy")] + pub skip_duplicates: bool, + #[serde(default = "default_delay")] + pub delay_sec: u64, +} +fn default_filter_mode() -> String { "include".into() } +fn truthy() -> bool { true } +fn default_delay() -> u64 { 3 } + +pub struct FolderMonitor { + app: AppHandle, + stop_tx: Mutex>>, + current: Mutex>, + seen: Arc>>, +} + +impl FolderMonitor { + pub fn new(app: AppHandle) -> Self { + Self { + app, + stop_tx: Mutex::new(None), + current: Mutex::new(None), + seen: Arc::new(Mutex::new(HashSet::new())), + } + } + + pub fn is_running(&self) -> bool { + self.stop_tx.lock().is_some() + } + + pub fn current(&self) -> Option { + self.current.lock().clone() + } + + pub async fn start(&self, settings: FolderMonitorSettings) -> Result<(), String> { + self.stop().await; + if !settings.enabled || settings.folder_path.is_empty() { + return Ok(()); + } + let root = PathBuf::from(&settings.folder_path); + if !root.exists() { + return Err(format!("Ordner existiert nicht: {}", root.display())); + } + let recursive = if settings.recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive }; + let extensions = parse_extensions(&settings.extensions); + let filter_include = settings.filter_mode == "include"; + let skip_dup = settings.skip_duplicates; + let delay = Duration::from_secs(settings.delay_sec.max(1)); + + let seen = self.seen.clone(); + + // Seed baseline so existing files don't get queued on startup. + if skip_dup { + let mut s = seen.lock(); + s.clear(); + walk_collect(&root, settings.recursive, &mut s); + } + + let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); + let (event_tx, mut event_rx) = mpsc::channel::>(32); + + let app = self.app.clone(); + tokio::spawn(async move { + while let Some(paths) = event_rx.recv().await { + if paths.is_empty() { continue; } + let filtered: Vec = paths.into_iter() + .filter(|p| path_matches(p, &extensions, filter_include)) + .filter_map(|p| p.to_str().map(|s| s.to_string())) + .collect(); + if filtered.is_empty() { continue; } + let _ = app.emit("folder-monitor-new-files", filtered); + } + }); + + let event_tx_cloned = event_tx.clone(); + let seen_cloned = seen.clone(); + tokio::task::spawn_blocking(move || { + let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::(); + let mut debouncer = match new_debouncer( + delay, + None, + move |res: DebounceEventResult| { let _ = debounce_tx.send(res); }, + ) { + Ok(d) => d, + Err(e) => { tracing::error!("folder-monitor debouncer: {e}"); return; } + }; + if let Err(e) = debouncer.watch(&root, recursive) { + tracing::error!("folder-monitor watch: {e}"); + return; + } + loop { + match debounce_rx.recv_timeout(Duration::from_millis(250)) { + Ok(Ok(events)) => { + let mut new_paths = Vec::new(); + for ev in events { + if matches!(ev.event.kind, EventKind::Create(_) | EventKind::Modify(_)) { + for p in &ev.paths { + if p.is_file() { + let mut s = seen_cloned.lock(); + if skip_dup && !s.insert(p.clone()) { continue; } + new_paths.push(p.clone()); + } + } + } + } + if !new_paths.is_empty() { + let _ = event_tx_cloned.blocking_send(new_paths); + } + } + Ok(Err(_)) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if stop_rx.try_recv().is_ok() { break; } + } + Err(_) => break, + } + } + }); + + *self.stop_tx.lock() = Some(stop_tx); + *self.current.lock() = Some(settings); + Ok(()) + } + + pub async fn stop(&self) { + let tx = self.stop_tx.lock().take(); + if let Some(tx) = tx { let _ = tx.send(()).await; } + *self.current.lock() = None; + } +} + +fn parse_extensions(s: &str) -> Vec { + s.split(',') + .map(|x| x.trim().trim_start_matches('.').to_lowercase()) + .filter(|x| !x.is_empty()) + .collect() +} + +fn path_matches(p: &Path, extensions: &[String], include: bool) -> bool { + if extensions.is_empty() { return true; } + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).unwrap_or_default(); + let listed = extensions.iter().any(|e| *e == ext); + if include { listed } else { !listed } +} + +fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for entry in rd.flatten() { + let p = entry.path(); + if p.is_file() { out.insert(p); } + else if p.is_dir() && recursive { walk_collect(&p, recursive, out); } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee78dc6..a459b0f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,15 +1,4 @@ // Multi-Hoster-Upload 2.0 — Tauri 2 / Rust port of the Electron original. -// -// Module layout -// ============= -// - `error` Unified error type used across the backend. -// - `secret` AES-GCM credential encryption (compatible with v1 .mhu backup format). -// - `config` Persistent config with atomic writes + write queue. -// - `throttle` Token-bucket bandwidth limiter. -// - `events` Shared DTOs emitted to the frontend (progress, rot-log, batch-done, ...). -// - `hosters` One module per hoster + shared XFS helpers. -// - `upload_manager` Orchestrates batches, concurrency, rotation, retries. -// - `commands` Tauri #[command] handlers — the IPC bridge to the frontend. pub mod error; pub mod events; @@ -18,14 +7,17 @@ pub mod config; pub mod throttle; pub mod hosters; pub mod upload_manager; +pub mod folder_monitor; +pub mod remote_server; +pub mod updater; +pub mod upload_log; pub mod commands; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Logging → stdout in dev, file in release (written under app_log_dir). tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -41,7 +33,6 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) .setup(|app| { - // Resolve app data dir + load config; inject shared state. let data_dir = app.path().app_data_dir() .expect("app_data_dir not available") .to_path_buf(); @@ -51,31 +42,93 @@ pub fn run() { config::ConfigStore::new(data_dir.join("config.json")) .expect("config store init failed"), ); - let manager = Arc::new(upload_manager::UploadManager::new(app.handle().clone())); + let fm = Arc::new(folder_monitor::FolderMonitor::new(app.handle().clone())); + let remote = Arc::new(remote_server::RemoteServer::new(manager.clone())); + let log_writer = upload_log::UploadLogWriter::new(app.handle().clone()); + *upload_log::WRITER.lock() = Some(log_writer.clone()); + manager.set_upload_log_writer(log_writer); app.manage(commands::AppState { - config: store, + config: store.clone(), uploads: manager, - rot_log_path: std::sync::Mutex::new(data_dir.join("account-rotation.log")), - upload_log_path: std::sync::Mutex::new(data_dir.join("fileuploader.log")), + folder_monitor: fm.clone(), + remote_server: remote.clone(), + rot_log_path: Mutex::new(data_dir.join("account-rotation.log")), + upload_log_path: Mutex::new(data_dir.join("fileuploader.log")), + always_on_top: parking_lot::Mutex::new(false), + shutdown_mode: parking_lot::Mutex::new("nothing".into()), }); + + // Restore folder-monitor + remote-server if configured. + if let Ok(cfg) = store.load() { + let gs = cfg.global_settings.clone(); + if gs.folder_monitor.enabled && !gs.folder_monitor.folder_path.is_empty() { + let settings = folder_monitor::FolderMonitorSettings { + enabled: true, + folder_path: gs.folder_monitor.folder_path.clone(), + recursive: gs.folder_monitor.recursive, + filter_mode: gs.folder_monitor.filter_mode.clone(), + extensions: gs.folder_monitor.extensions.clone(), + skip_duplicates: gs.folder_monitor.skip_duplicates, + delay_sec: gs.folder_monitor.delay_sec, + }; + let fm_clone = fm.clone(); + tokio::spawn(async move { let _ = fm_clone.start(settings).await; }); + } + if gs.remote.enabled && !gs.remote.token.is_empty() { + let rs = remote.clone(); + let port = gs.remote.port; + let tok = gs.remote.token.clone(); + tokio::spawn(async move { let _ = rs.start(port, tok).await; }); + } + } Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::get_config, commands::save_config, - commands::start_batch, + commands::get_history, + commands::clear_history, + commands::export_history, + commands::get_hoster_settings, + commands::save_hoster_settings, + commands::get_global_settings, + commands::save_global_settings, + commands::set_always_on_top, + commands::get_always_on_top, + commands::set_shutdown_after_finish, + commands::get_shutdown_after_finish, + commands::cancel_shutdown, + commands::resolve_folder_files, + commands::copy_to_clipboard, + commands::start_upload, + commands::add_jobs_to_batch, commands::cancel_batch, commands::cancel_jobs, - commands::add_jobs, + commands::finish_after_active, commands::run_health_check, commands::export_backup, commands::import_backup, + commands::import_backup_saved, + commands::read_own_upload_log, + commands::import_upload_log, + commands::save_text_file, commands::open_log_folder, - commands::get_history, - commands::clear_history, commands::read_rotation_log, + commands::get_version, + commands::check_for_update, + commands::install_update, + commands::folder_monitor_start, + commands::folder_monitor_stop, + commands::folder_monitor_status, + commands::remote_get_settings, + commands::remote_save_settings, + commands::remote_generate_token, + commands::remote_status, + commands::show_drop_target, + commands::hide_drop_target, + commands::debug_log, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/remote_server.rs b/src-tauri/src/remote_server.rs new file mode 100644 index 0000000..bc36652 --- /dev/null +++ b/src-tauri/src/remote_server.rs @@ -0,0 +1,112 @@ +//! Remote-control HTTP server — port of `lib/remote-server.js`. +//! +//! Exposes a minimal JSON API behind bearer-token auth for remote control +//! of the queue. Serves no HTML in this implementation; the embedded v1 web +//! dashboard asset is kept in the v1 build for now. +//! +//! Endpoints: +//! GET /api/status → { state, activeJobs, pendingJobs, ... } +//! GET /api/history → recent batch history +//! POST /api/control/cancel → cancel current batch +//! POST /api/control/finish-after → finish after active + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use parking_lot::Mutex; +use serde_json::json; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::Notify; + +use crate::upload_manager::UploadManager; + +pub struct RemoteServer { + shutdown: Mutex>>, + uploads: Arc, + token: Mutex, +} + +impl RemoteServer { + pub fn new(uploads: Arc) -> Self { + Self { + shutdown: Mutex::new(None), + uploads, + token: Mutex::new(String::new()), + } + } + + pub fn is_running(&self) -> bool { self.shutdown.lock().is_some() } + + pub async fn start(&self, port: u16, token: String) -> Result<(), String> { + self.stop().await; + if port == 0 { return Err("Port 0 ungültig".into()); } + *self.token.lock() = token.clone(); + + let state = AppStateRem { token: Arc::new(token), uploads: self.uploads.clone() }; + let app = Router::new() + .route("/api/status", get(status)) + .route("/api/control/cancel", post(control_cancel)) + .route("/api/control/finish-after", post(control_finish_after)) + .with_state(state); + + let shutdown = Arc::new(Notify::new()); + *self.shutdown.lock() = Some(shutdown.clone()); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let listener = TcpListener::bind(addr).await.map_err(|e| e.to_string())?; + let server_shutdown = shutdown.clone(); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async move { server_shutdown.notified().await }) + .await; + }); + Ok(()) + } + + pub async fn stop(&self) { + let s = self.shutdown.lock().take(); + if let Some(s) = s { s.notify_waiters(); } + } +} + +#[derive(Clone)] +struct AppStateRem { + token: Arc, + uploads: Arc, +} + +fn authorize(headers: &HeaderMap, token: &str) -> Result<(), StatusCode> { + if token.is_empty() { return Err(StatusCode::UNAUTHORIZED); } + let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or(""); + let expected = format!("Bearer {token}"); + if auth != expected { return Err(StatusCode::UNAUTHORIZED); } + Ok(()) +} + +async fn status(State(st): State, headers: HeaderMap) -> impl IntoResponse { + if authorize(&headers, &st.token).is_err() { + return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "unauthorized" }))).into_response(); + } + Json(json!({ + "state": if st.uploads.is_running() { "uploading" } else { "idle" }, + "version": env!("CARGO_PKG_VERSION"), + })).into_response() +} + +async fn control_cancel(State(st): State, headers: HeaderMap) -> impl IntoResponse { + if authorize(&headers, &st.token).is_err() { return StatusCode::UNAUTHORIZED; } + st.uploads.cancel(); + StatusCode::NO_CONTENT +} + +async fn control_finish_after(State(st): State, headers: HeaderMap) -> impl IntoResponse { + if authorize(&headers, &st.token).is_err() { return StatusCode::UNAUTHORIZED; } + st.uploads.finish_after_active(); + StatusCode::NO_CONTENT +} diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..4934e6f --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,75 @@ +//! Auto-updater — checks the Gitea releases endpoint for a newer version +//! than the one currently running. Downloads are opt-in (user clicks +//! "Install Update"), not automatic. +//! +//! Port of the v1 `lib/updater.js` semver-compare + Gitea polling flow. + +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +const REPO: &str = "Administrator/Multi-Hoster-Upload"; +const BASE: &str = "https://git.24-music.de/api/v1/repos"; + +#[derive(Serialize, Default, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCheck { + pub available: bool, + pub current_version: String, + pub latest_version: Option, + pub download_url: Option, + pub release_notes: Option, +} + +#[derive(Deserialize)] +struct GiteaRelease { + tag_name: String, + body: Option, + assets: Option>, +} + +#[derive(Deserialize)] +struct GiteaAsset { + name: String, + browser_download_url: String, +} + +pub async fn check() -> UpdateCheck { + let current = env!("CARGO_PKG_VERSION").to_string(); + let mut out = UpdateCheck { + available: false, + current_version: current.clone(), + ..Default::default() + }; + + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .user_agent("multi-hoster-upload/2.0") + .build() + else { return out }; + + let url = format!("{BASE}/{REPO}/releases?limit=10"); + let Ok(resp) = client.get(&url).send().await else { return out }; + if !resp.status().is_success() { return out; } + let Ok(list) = resp.json::>().await else { return out }; + + let Ok(current_v) = Version::parse(current.trim_start_matches('v')) else { return out }; + + for r in list { + let raw = r.tag_name.trim_start_matches('v'); + let Ok(v) = Version::parse(raw) else { continue }; + if v > current_v { + out.available = true; + out.latest_version = Some(r.tag_name.clone()); + out.release_notes = r.body.clone(); + // Prefer MSI > NSIS Setup > fallback. + let assets = r.assets.unwrap_or_default(); + let url = assets.iter().find(|a| a.name.ends_with(".msi")) + .or_else(|| assets.iter().find(|a| a.name.to_lowercase().contains("setup.exe"))) + .or_else(|| assets.iter().find(|a| a.name.ends_with(".exe"))); + out.download_url = url.map(|a| a.browser_download_url.clone()); + break; + } + } + out +} diff --git a/src-tauri/src/upload_log.rs b/src-tauri/src/upload_log.rs new file mode 100644 index 0000000..e505783 --- /dev/null +++ b/src-tauri/src/upload_log.rs @@ -0,0 +1,203 @@ +//! Upload log writer with fallback ladder — port of the v1 +//! `appendUploadLog` + `_resolveUploadLogTarget` logic. +//! +//! Tries the user-configured path first; falls back to the current user's +//! Desktop, then AppData. On a successful fallback, auto-persists the +//! working path into `globalSettings.logFilePath` so subsequent writes go +//! straight there. + +use chrono::{DateTime, Local}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, Manager}; + +pub struct UploadLogWriter { + app: AppHandle, + cached_target: Mutex>, + fallback_warned: Mutex, +} + +#[derive(Clone)] +struct ResolvedTarget { + path: PathBuf, + key: String, + is_fallback: bool, +} + +impl UploadLogWriter { + pub fn new(app: AppHandle) -> Arc { + Arc::new(Self { + app, + cached_target: Mutex::new(None), + fallback_warned: Mutex::new(false), + }) + } + + pub async fn append(&self, hoster: &str, link: &str, file_name: &str) { + let now: DateTime = Local::now(); + let date_str = now.format("%Y-%m-%d %H:%M:%S").to_string(); + let line = format!("{date_str}|{hoster}|{link}||{file_name}|\n"); + + let configured = self.configured_path(); + let session_log = self.session_log_enabled(); + let key = format!("{}|{}", configured.display(), if session_log { date_today() } else { String::new() }); + + // Try cached target first. + if let Some(t) = self.cached_target.lock().clone() { + if t.key == key { + if append_line(&t.path, &line).is_ok() { + return; + } + } + } + + // Resolve: primary → desktop → userData. + let primary = if session_log { + self.daily_variant(&configured) + } else { + configured.clone() + }; + if append_line(&primary, &line).is_ok() { + *self.cached_target.lock() = Some(ResolvedTarget { + path: primary, key, is_fallback: false, + }); + return; + } + // Desktop fallback + if let Some(desktop) = desktop_dir() { + let p = desktop.join(self.base_file_name(session_log)); + if append_line(&p, &line).is_ok() { + *self.cached_target.lock() = Some(ResolvedTarget { + path: p.clone(), key, is_fallback: true, + }); + self.maybe_warn_and_persist(&p); + return; + } + } + // AppData fallback + if let Some(ad) = app_data_dir(&self.app) { + let p = ad.join(self.base_file_name(session_log)); + if append_line(&p, &line).is_ok() { + *self.cached_target.lock() = Some(ResolvedTarget { + path: p.clone(), key, is_fallback: true, + }); + self.maybe_warn_and_persist(&p); + return; + } + } + tracing::warn!("upload-log: all targets failed"); + } + + fn base_file_name(&self, session_log: bool) -> String { + if session_log { + format!("fileuploader-{}.log", date_today()) + } else { + "fileuploader.log".to_string() + } + } + + fn daily_variant(&self, configured: &Path) -> PathBuf { + let dir = configured.parent().unwrap_or(Path::new(".")); + let stem = configured.file_stem().and_then(|s| s.to_str()).unwrap_or("fileuploader"); + let ext = configured.extension().and_then(|s| s.to_str()).unwrap_or("log"); + dir.join(format!("{}-{}.{}", stem, date_today(), ext)) + } + + fn configured_path(&self) -> PathBuf { + // Pull from shared AppState when present. Fallback: next to exe. + if let Some(state) = self.app.try_state::() { + let cfg = state.config.load().ok(); + if let Some(cfg) = cfg { + let custom = cfg.global_settings.log_file_path.trim().to_string(); + if !custom.is_empty() { + return PathBuf::from(custom); + } + } + } + default_log_path(&self.app) + } + + fn session_log_enabled(&self) -> bool { + self.app.try_state::() + .and_then(|s| s.config.load().ok()) + .map(|c| c.global_settings.session_log) + .unwrap_or(false) + } + + fn maybe_warn_and_persist(&self, path: &Path) { + let mut warned = self.fallback_warned.lock(); + if *warned { return; } + *warned = true; + let _ = self.app.emit("upload-log-fallback", + serde_json::json!({ "fallbackPath": path.display().to_string() })); + + // Auto-persist into config so next session writes here directly. + if let Some(state) = self.app.try_state::() { + let store = state.config.clone(); + let to_save = path.to_path_buf(); + tauri::async_runtime::spawn(async move { + if let Ok(cfg) = store.load() { + let mut gs = cfg.global_settings.clone(); + // Strip daily suffix when daily-log is active — same as v1. + let save_path = if gs.session_log { + strip_daily_suffix(&to_save) + } else { + to_save.clone() + }; + gs.log_file_path = save_path.display().to_string(); + let _ = store.save_global(gs).await; + } + }); + } + } +} + +fn default_log_path(app: &AppHandle) -> PathBuf { + // Packaged: next to the exe. Dev: cwd. + let exe = std::env::current_exe().ok(); + let dir = exe.and_then(|p| p.parent().map(|p| p.to_path_buf())) + .or_else(|| app_data_dir(app)) + .unwrap_or_else(|| PathBuf::from(".")); + dir.join("fileuploader.log") +} + +fn desktop_dir() -> Option { + dirs_desktop() +} + +#[cfg(target_os = "windows")] +fn dirs_desktop() -> Option { + std::env::var_os("USERPROFILE").map(|p| PathBuf::from(p).join("Desktop")) +} +#[cfg(not(target_os = "windows"))] +fn dirs_desktop() -> Option { + std::env::var_os("HOME").map(|p| PathBuf::from(p).join("Desktop")) +} + +fn app_data_dir(app: &AppHandle) -> Option { + app.path().app_data_dir().ok() +} + +fn date_today() -> String { + Local::now().format("%Y-%m-%d").to_string() +} + +fn append_line(path: &Path, line: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?; + std::io::Write::write_all(&mut f, line.as_bytes())?; + Ok(()) +} + +fn strip_daily_suffix(path: &Path) -> PathBuf { + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("log"); + let dir = path.parent().unwrap_or(Path::new(".")); + // Remove trailing -YYYY-MM-DD if present + let stripped = regex::Regex::new(r"-\d{4}-\d{2}-\d{2}$").unwrap().replace(stem, ""); + dir.join(format!("{}.{}", stripped, ext)) +} + +pub static WRITER: Lazy>>> = Lazy::new(|| Mutex::new(None)); diff --git a/src-tauri/src/upload_manager.rs b/src-tauri/src/upload_manager.rs index a36f2a8..cc57455 100644 --- a/src-tauri/src/upload_manager.rs +++ b/src-tauri/src/upload_manager.rs @@ -51,6 +51,7 @@ pub struct Job { pub struct UploadManager { app: AppHandle, + log_writer: parking_lot::Mutex>>, running: AtomicBool, stop_after_active: AtomicBool, start_time: parking_lot::Mutex>, @@ -83,9 +84,14 @@ pub struct UploadManager { } impl UploadManager { + pub fn set_upload_log_writer(&self, w: std::sync::Arc) { + *self.log_writer.lock() = Some(w); + } + pub fn new(app: AppHandle) -> Self { Self { app, + log_writer: parking_lot::Mutex::new(None), running: AtomicBool::new(false), stop_after_active: AtomicBool::new(false), start_time: parking_lot::Mutex::new(None), @@ -312,6 +318,7 @@ impl UploadManager { self.session_bytes.fetch_add(meta.len(), Ordering::Relaxed); } self.emit_final(&job, "done", Some(&res), None); + self.append_upload_log(&job, &res).await; self.record_result(&job, "done", Some(res), None).await; } Err(e) => { @@ -626,6 +633,16 @@ impl UploadManager { self.emit("upload-progress", &evt); } + async fn append_upload_log(&self, job: &Job, result: &UploadResult) { + let w = self.log_writer.lock().clone(); + if let Some(w) = w { + let link = result.download_url.as_deref().or(result.embed_url.as_deref()).unwrap_or(""); + if !link.is_empty() { + w.append(&job.hoster, link, &job.file_name).await; + } + } + } + fn emit_final(&self, job: &Job, status: &str, result: Option<&UploadResult>, err: Option<&str>) { let evt = ProgressEvent { job_id: job.id.clone(), diff --git a/src/app.js b/src/app.js index 8cf0f31..4d681a2 100644 --- a/src/app.js +++ b/src/app.js @@ -1,463 +1,4056 @@ -// Multi-Hoster-Upload 2.0 — minimal frontend demonstrating the Tauri bridge. -// Uses the global Tauri runtime (withGlobalTauri: true). +const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; -const { invoke } = window.__TAURI__.core; -const { listen } = window.__TAURI__.event; -const dialog = window.__TAURI__.dialog; +// Dropdown options for "Add Account" modal: value -> label +const HOSTER_ADD_OPTIONS = [ + { value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' }, + { value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' }, + { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, + { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, + { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, + { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' }, + { value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' } +]; -let config = null; -let selectedFiles = []; -let selectedHosters = []; -let queueJobs = []; -const jobById = new Map(); +// --- State --- +let selectedFiles = []; // { path, name, size } +let selectedUploadHosters = []; +let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; +let hosterSettings = {}; let uploading = false; +let healthCheckRunning = false; +let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } +let editingAccountId = null; // null = adding, string = editing account by ID +let autoHealthCheckEnabled = true; +let queuePersistTimer = null; +let settingsSaveTimer = null; +let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; +const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; +const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx'; +const STARTABLE_QUEUE_STATUSES = new Set(['preview', 'queued', 'error', 'aborted', 'skipped']); -function $(id) { return document.getElementById(id); } -function escHtml(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' })[c]); } -function uuid() { return 'j-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); } - -function showToast(msg, ms) { - const el = $('toast'); - el.textContent = msg; - el.classList.add('show'); - clearTimeout(showToast._t); - showToast._t = setTimeout(() => el.classList.remove('show'), ms || 2200); +function isStartableQueueStatus(status) { + return STARTABLE_QUEUE_STATUSES.has(status); } -// Tab switching -(function () { - const tabs = document.querySelectorAll('.tab'); - const views = document.querySelectorAll('.view'); - let active = document.querySelector('.tab.active'); - tabs.forEach(function (t) { - t.addEventListener('click', function () { - if (t === active) return; - if (active) active.classList.remove('active'); - t.classList.add('active'); - views.forEach(function (v) { v.classList.toggle('active', v.id === t.dataset.view + '-view'); }); - active = t; - if (t.dataset.view === 'log') refreshLog(); - }); - }); -})(); +function isStartableQueueJob(job) { + return !!job && isStartableQueueStatus(job.status); +} -async function loadConfig() { - config = await invoke('get_config'); - renderAccounts(); - renderHosterCheckboxes(); +// Queue state +let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link } +const _jobIndexById = new Map(); // id -> job (O(1) lookup) +const _jobIndexByUploadId = new Map(); // uploadId -> job +const selectedJobIds = new Set(); +let _sessionTotalBytes = 0; // Total bytes ever added to queue this session +let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs) +const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes +const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes +const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone) +const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks) +const queueSortState = { key: 'filename', direction: 'asc' }; + +// History state +let historyRowsData = []; +let historySortState = { key: 'date', direction: 'desc' }; + +// Session-specific files for the "Files" panel (resets each session) +let sessionFilesData = []; +const recentSortState = { key: 'date', direction: 'desc' }; +const selectedRecentIds = new Set(); +// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar. +let _sessionDoneCount = 0; +let _sessionErrorCount = 0; +// O(1) dedup for maybeAddSessionFile (was O(n) sessionFilesData.some). +// Huge with thousands of rows × thousands of incoming results. +const _sessionFileKeys = new Set(); + +// --- Init --- +async function init() { + config = await window.api.getConfig(); + hosterSettings = config.hosterSettings || {}; + autoHealthCheckEnabled = loadAutoCheckPreference(); + ensureAccountStatusEntries(); + syncSelectedUploadHosters(); + restoreQueueStateFromConfig(); + await _autoDeduplicateFromLog(); + renderHosterSummary(); + renderHosterModal(); renderSettings(); -} - -function hosterLabel(h) { - const map = { 'clouddrop.cc': 'Clouddrop', 'byse.sx': 'Byse', 'vidmoly.me': 'Vidmoly', - 'doodstream.com': 'Doodstream', 'voe.sx': 'VOE' }; - return map[h] || h; -} -function accountLabel(h, a) { - return a.label || a.username || (a.api_key ? 'API ' + String(a.api_key).slice(0, 8) + String.fromCharCode(8230) : 'Account ' + String(a.id).slice(-6)); -} -function hasCreds(_h, a) { - if (a.auth_type === 'api') return !!a.api_key; - if (a.auth_type === 'login') return !!(a.username && a.password); - return !!a.api_key || !!(a.username && a.password); -} -function statusLabel(s) { - const map = { preview: 'Vorschau', queued: 'Wartet', 'getting-server': 'Server...', uploading: 'Upload', - retrying: 'Retry', done: 'Fertig', error: 'Fehler', aborted: 'Abgebrochen' }; - return map[s] || s; -} - -// Accounts -function renderAccounts() { - const list = $('accountsList'); - while (list.firstChild) list.removeChild(list.firstChild); - if (!config || !config.hosters) { - const p = document.createElement('p'); - p.textContent = 'Kein Config geladen.'; - list.appendChild(p); - return; - } - let anyAccount = false; - for (const hoster of Object.keys(config.hosters)) { - const accounts = config.hosters[hoster] || []; - if (!accounts.length) continue; - anyAccount = true; - const h = document.createElement('h3'); - h.style.margin = '12px 0 6px'; - h.textContent = hosterLabel(hoster); - list.appendChild(h); - accounts.forEach(function (a, idx) { - list.appendChild(buildAccountCard(hoster, a, idx)); - }); - } - if (!anyAccount) { - const p = document.createElement('p'); - p.textContent = 'Keine Accounts. Klicke oben "+ Account hinzufügen".'; - list.appendChild(p); - } -} - -function buildAccountCard(hoster, a, idx) { - const disabled = a.enabled === false; - const card = document.createElement('div'); - card.className = 'account-card' + (disabled ? ' disabled' : ''); - card.dataset.hoster = hoster; - card.dataset.id = a.id; - const info = document.createElement('div'); - info.className = 'account-info'; - const title = document.createElement('div'); - title.className = 'title'; - title.textContent = accountLabel(hoster, a); - const prio = document.createElement('span'); - prio.style.color = 'var(--text-dim)'; - prio.style.fontSize = '11px'; - prio.style.marginLeft = '6px'; - prio.textContent = '#' + (idx + 1); - title.appendChild(prio); - const sub = document.createElement('div'); - sub.className = 'sub'; - sub.textContent = a.auth_type === 'api' ? 'API Key' : ('Login ' + (a.username || '')); - info.appendChild(title); - info.appendChild(sub); - const status = document.createElement('span'); - status.className = 'account-status'; - status.textContent = disabled ? 'Deaktiviert' : 'Aktiv'; - const toggleBtn = document.createElement('button'); - toggleBtn.className = 'btn'; - toggleBtn.dataset.act = 'toggle'; - toggleBtn.textContent = disabled ? 'Aktivieren' : 'Deaktivieren'; - toggleBtn.addEventListener('click', onAccountAction); - const delBtn = document.createElement('button'); - delBtn.className = 'btn'; - delBtn.dataset.act = 'delete'; - delBtn.textContent = 'Löschen'; - delBtn.addEventListener('click', onAccountAction); - card.appendChild(info); - card.appendChild(status); - card.appendChild(toggleBtn); - card.appendChild(delBtn); - return card; -} - -async function onAccountAction(e) { - const card = e.currentTarget.closest('.account-card'); - const hoster = card.dataset.hoster; - const id = card.dataset.id; - const act = e.currentTarget.dataset.act; - if (!config.hosters[hoster]) return; - if (act === 'toggle') { - const acc = config.hosters[hoster].find(function (a) { return a.id === id; }); - if (acc) acc.enabled = !acc.enabled; - } else if (act === 'delete') { - if (!confirm('Account wirklich löschen?')) return; - config.hosters[hoster] = config.hosters[hoster].filter(function (a) { return a.id !== id; }); - } - await invoke('save_config', { config: config }); renderAccounts(); - renderHosterCheckboxes(); -} + setupListeners(); + setupDragDrop(); + restoreQueueColumnWidths(); + loadHistory(); + renderRecentUploadsPanel(); + updateUploadView(); + updateStatusBar(); -$('addAccountBtn').addEventListener('click', function () { - $('accUsername').value = ''; - $('accPassword').value = ''; - $('accApiKey').value = ''; - onAccHosterChange(); - $('accountModal').style.display = 'flex'; -}); + // Version display + try { + const version = await window.api.getVersion(); + const versionLabel = document.getElementById('versionLabel'); + if (versionLabel) versionLabel.textContent = `v${version}`; + } catch {} -function onAccHosterChange() { - const h = $('accHoster').value; - const needsLogin = h === 'vidmoly.me' || h === 'doodstream.com' || h === 'voe.sx'; - $('accLoginRow').style.display = needsLogin ? '' : 'none'; - $('accPasswordRow').style.display = needsLogin ? '' : 'none'; - $('accApiKeyRow').style.display = needsLogin ? 'none' : ''; -} -$('accHoster').addEventListener('change', onAccHosterChange); -$('accCancelBtn').addEventListener('click', function () { $('accountModal').style.display = 'none'; }); + // Update listeners + window.api.onUpdateAvailable(showUpdateBanner); + window.api.onUpdateProgress(handleUpdateProgress); -$('accSaveBtn').addEventListener('click', async function () { - const hoster = $('accHoster').value; - const needsLogin = $('accLoginRow').style.display !== 'none'; - const acc = { - id: hoster + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6), - enabled: true, - auth_type: needsLogin ? 'login' : 'api', - username: needsLogin ? $('accUsername').value.trim() : '', - password: needsLogin ? $('accPassword').value : '', - api_key: needsLogin ? '' : $('accApiKey').value.trim(), - label: null, - }; - if (!config.hosters[hoster]) config.hosters[hoster] = []; - config.hosters[hoster].push(acc); - await invoke('save_config', { config: config }); - $('accountModal').style.display = 'none'; - renderAccounts(); - renderHosterCheckboxes(); - showToast('Account gespeichert'); -}); - -// Hoster selection -function renderHosterCheckboxes() { - const container = $('hosterCheckboxes'); - while (container.firstChild) container.removeChild(container.firstChild); - const available = ['clouddrop.cc', 'byse.sx', 'vidmoly.me', 'doodstream.com', 'voe.sx'] - .filter(function (h) { - return config && config.hosters[h] && config.hosters[h].some(function (a) { return a.enabled !== false && hasCreds(h, a); }); - }); - if (!available.length) { - const s = document.createElement('span'); - s.style.color = 'var(--text-dim)'; - s.textContent = 'Keine Accounts mit Credentials'; - container.appendChild(s); - return; - } - available.forEach(function (h) { - const lbl = document.createElement('label'); - lbl.className = 'hoster-checkbox' + (selectedHosters.indexOf(h) >= 0 ? ' checked' : ''); - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.value = h; - cb.checked = selectedHosters.indexOf(h) >= 0; - cb.addEventListener('change', function () { - selectedHosters = Array.from(container.querySelectorAll('input:checked')).map(function (c) { return c.value; }); - renderHosterCheckboxes(); - updateStartBtn(); - }); - lbl.appendChild(cb); - lbl.appendChild(document.createTextNode(' ' + hosterLabel(h))); - container.appendChild(lbl); + // Upload event listeners — debug log only on state transitions; the 'uploading' + // tick fires 4×/sec per active job and an IPC roundtrip per event would + // backlog the renderer↔main channel with hundreds of messages/sec. + window.api.onUploadProgress((data) => { + if (data.status !== 'uploading') { + window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || '')); + } + handleProgress(data); }); -} - -function updateStartBtn() { - $('startBtn').disabled = uploading || !selectedFiles.length || !selectedHosters.length; -} - -// File picker -$('pickFilesBtn').addEventListener('click', async function () { - const picked = await dialog.open({ multiple: true, directory: false }); - if (!picked) return; - const arr = Array.isArray(picked) ? picked : [picked]; - arr.forEach(function (p) { - if (!selectedFiles.find(function (f) { return f.path === p; })) { - selectedFiles.push({ path: p, name: p.split(/[\\/]/).pop() }); + window.api.onUploadBatchDone((data) => { + window.api.debugLog('RX upload-batch-done'); + handleBatchDone(data); + }); + window.api.onUploadStats((data) => { + // Stats fire every second per upload session — skip while uploading. + if (data.state !== 'uploading') { + window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs); + } + handleStats(data); + }); + window.api.onShutdownCountdown(handleShutdownCountdown); + window.api.onUploadLogFallback((data) => { + const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)'; + showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000); + }); + window.api.onLogPathAutoUpdated((data) => { + if (!data || !data.logFilePath) return; + // Keep the in-memory config and the visible Settings input in sync so + // the user sees the path that's actually being written to, and the + // next save from the UI doesn't revert it. + if (config && config.globalSettings) config.globalSettings.logFilePath = data.logFilePath; + const input = document.getElementById('logFilePathInput'); + if (input) input.value = data.logFilePath; + showCopyToast(`Log-Pfad automatisch auf funktionierenden Ordner gesetzt`, 5000); + }); + window.api.onAccountRotationLog((entry) => { + // Surface only the user-visible rotation events as toasts; full detail + // goes to account-rotation.log. Keep it quiet otherwise. + if (!entry || !entry.event) return; + const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : ''; + if (entry.event === 'rotate') { + showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`); + } else if (entry.event === 'rotation-end') { + showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`); + } else if (entry.event === 'final-error') { + showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`); } }); - renderQueuePreview(); - updateStartBtn(); -}); -function renderQueuePreview() { - const tbody = $('queueBody'); - while (tbody.firstChild) tbody.removeChild(tbody.firstChild); - if (!selectedFiles.length && !queueJobs.length) { - const tr = document.createElement('tr'); - const td = document.createElement('td'); - td.colSpan = 6; - td.style.color = 'var(--text-dim)'; - td.style.textAlign = 'center'; - td.style.padding = '20px'; - td.textContent = 'Keine Dateien'; - tr.appendChild(td); - tbody.appendChild(tr); + // Folder monitor: auto-queue new files + window.api.onFolderMonitorNewFiles((files) => { + window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); + const fm = config.globalSettings && config.globalSettings.folderMonitor; + const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : []; + + if (fmHosters.length > 0) { + // Pre-selected hosters: set them as active selection and add directly to queue + selectedUploadHosters = fmHosters.slice(); + const existing = new Set(); + for (const f of selectedFiles) existing.add(f.path); + for (const f of _pendingFiles) existing.add(f.path); + const newFiles = []; + for (const p of files) { + if (existing.has(p)) continue; + existing.add(p); + const name = p.split('\\').pop().split('/').pop(); + newFiles.push({ path: p, name, size: null }); + } + if (newFiles.length > 0) { + const newPaths = new Set(newFiles.map(f => f.path)); + selectedFiles.push(...newFiles); + buildQueuePreview(); + updateUploadView(); + if (fm.autoStart && !uploading && !healthCheckRunning) { + startUpload(); + } else if (uploading) { + // Inject new preview jobs into the running batch + const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file)); + if (newJobs.length > 0) { + newJobs.forEach(j => { j.status = 'queued'; }); + renderQueueTable(); + window.api.addJobsToBatch({ + jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) + }).then(result => { _markSkippedJobs(result); }).catch(() => {}); + persistQueueStateSoon(true); + } + } + } + } else { + // No pre-selected hosters: open modal + addPathsToQueue(files); + } + }); + + // Account switched notification + window.api.onAccountSwitched((data) => { + window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`); + }); + + // Drop target window: files dropped on the small floating window + window.api.onDropTargetFiles((paths) => { + addPathsToQueue(paths); + }); + + // Remote client count updates (registered once, not per renderSettings call) + window.api.onRemoteClientCount(() => { + const el = document.getElementById('remoteConnectionStatus'); + if (el && el.style.color === 'rgb(16, 185, 129)') { + window.api.remoteStatus().then(status => { + if (status.running) { + el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; + } + }).catch(() => {}); + } + }); + + window.api.debugLog('init complete, all listeners registered'); + + // Restore always-on-top state + try { + const onTop = await window.api.getAlwaysOnTop(); + alwaysOnTopState = !!onTop; + } catch {} + + scheduleStartupAccountCheck(); +} + +// --- Tab switching --- +let _historyDirty = false; +function _isHistoryTabActive() { + const tab = document.querySelector('.tab.active'); + return !!(tab && tab.dataset.view === 'history'); +} +// Cache the tab/view collections once and use event delegation on the parent +// so tab switches don't trigger three querySelectorAll walks per click. +(() => { + const tabs = Array.from(document.querySelectorAll('.tab')); + const views = Array.from(document.querySelectorAll('.view')); + const tabsByView = {}; + const viewsById = {}; + for (const t of tabs) tabsByView[t.dataset.view] = t; + for (const v of views) viewsById[v.id] = v; + let activeTab = tabs.find(t => t.classList.contains('active')) || tabs[0]; + + const handle = (target) => { + const tab = target.closest('.tab'); + if (!tab || tab === activeTab) return; + if (activeTab) { + activeTab.classList.remove('active'); + const prevView = viewsById[`${activeTab.dataset.view}-view`]; + if (prevView) prevView.classList.remove('active'); + } + tab.classList.add('active'); + const nextView = viewsById[`${tab.dataset.view}-view`]; + if (nextView) nextView.classList.add('active'); + activeTab = tab; + if (tab.dataset.view === 'history') { + _historyDirty = false; + loadHistory(); + } + }; + + const tabBar = tabs[0] && tabs[0].parentElement; + if (tabBar) { + tabBar.addEventListener('click', (e) => handle(e.target)); + } else { + // Fallback: bind per-tab if somehow no common parent + tabs.forEach(t => t.addEventListener('click', () => handle(t))); + } +})(); + +// --- Hoster selection --- +function accountHasCreds(name, account) { + if (!account) return false; + if (account.authType === 'api') return !!account.apiKey; + if (account.authType === 'login') return !!(account.username && account.password); + // Fallback + if (name === 'vidmoly.me') return !!(account.username && account.password); + if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; + return !!account.apiKey; +} + +// Returns hosters that have at least one enabled account with credentials +function getAvailableHosters() { + const result = []; + for (const name of HOSTERS) { + const accounts = config.hosters[name]; + if (!Array.isArray(accounts)) continue; + const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); + if (hasEnabledAccount) result.push({ name }); + } + return result; +} + +function syncSelectedUploadHosters() { + const available = new Set(getAvailableHosters().map(item => item.name)); + selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); + if (selectedUploadHosters.length === 0) { + selectedUploadHosters = HOSTERS.filter(name => { + const accounts = config.hosters[name]; + return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a)); + }); + } +} + +function getSelectedHosters() { + return selectedUploadHosters.slice(); +} + +function getHosterLabel(name) { + const labels = { + 'doodstream.com': 'Doodstream', + 'voe.sx': 'VOE', + 'vidmoly.me': 'Vidmoly', + 'byse.sx': 'Byse', + 'clouddrop.cc': 'Clouddrop' + }; + return labels[name] || name; +} + +function getAccountAuthLabel(account) { + if (!account) return ''; + if (account.authType === 'api') return 'API'; + if (account.authType === 'login') return 'Web Login'; + return ''; +} + +function getAccountDisplayName(name, account) { + const authLabel = getAccountAuthLabel(account); + return authLabel + ? `${getHosterLabel(name)} (${authLabel})` + : getHosterLabel(name); +} + +function maskCredential(value, keep = 4) { + const text = String(value || '').trim(); + if (!text) return ''; + if (text.length <= keep) return text; + return `${text.slice(0, keep)}…${text.slice(-2)}`; +} + +function ensureAccountStatusEntries() { + const nextStatuses = {}; + for (const { account } of getAllAccountsFlat()) { + if (account.id) { + nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' }; + } + } + accountStatuses = nextStatuses; +} + +// Returns flat array of all accounts: [{ name, account, index }] +function getAllAccountsFlat() { + const result = []; + for (const name of HOSTERS) { + const accounts = config.hosters[name]; + if (!Array.isArray(accounts)) continue; + accounts.forEach((account, index) => result.push({ name, account, index })); + } + return result; +} + +// Returns flat array of accounts with credentials +function getAccountsWithCredsFlat() { + return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account)); +} + +// Find account by ID across all hosters +function findAccountById(accountId) { + for (const name of HOSTERS) { + const accounts = config.hosters[name]; + if (!Array.isArray(accounts)) continue; + const account = accounts.find(a => a.id === accountId); + if (account) return { name, account }; + } + return null; +} + +function scheduleStartupAccountCheck() { + const accounts = getAccountsWithCredsFlat(); + if (!accounts.length) return; + setTimeout(() => { + runHealthCheck('startup').catch(() => {}); + }, 500); +} + +function renderHosterSummary() { + const summary = document.getElementById('hosterSummary'); + if (!summary) return; + const hosters = getSelectedHosters(); + if (hosters.length === 0) { + summary.textContent = 'Keine Upload-Ziele ausgewählt'; + } else if (hosters.length === 1) { + summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`; + } else { + summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`; + } +} + +function renderHosterModal() { + const list = document.getElementById('hosterModalList'); + const hint = document.getElementById('hosterModalHint'); + if (!list || !hint) return; + + const available = getAvailableHosters(); + if (available.length === 0) { + list.innerHTML = ''; + hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.'; return; } - if (queueJobs.length) { - queueJobs.forEach(function (j) { tbody.appendChild(buildQueueRow(j)); }); - } else { - selectedFiles.forEach(function (f) { - selectedHosters.forEach(function (h) { - tbody.appendChild(buildPreviewRow(f, h)); - }); - }); - } -} -function buildQueueRow(j) { - const tr = document.createElement('tr'); - tr.className = 'queue-row status-' + j.status; - tr.dataset.id = j.id; - const pct = Math.round((j.progress || 0) * 100); - const link = j.result ? (j.result.download_url || '') : ''; + list.innerHTML = available.map(item => { + const checked = selectedUploadHosters.includes(item.name); + // Get first enabled account's status for subtitle + const accounts = config.hosters[item.name] || []; + const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a)); + const accountCount = enabledAccounts.length; + let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`; + // Check if any account has ok status + const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok'); + const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error'); + if (hasOk) subtitle += ' • Bereit'; + else if (hasError) subtitle += ' • Fehler'; + return ` + + `; + }).join(''); - const td1 = document.createElement('td'); td1.textContent = j.fileName || j.file_name; - const td2 = document.createElement('td'); td2.textContent = hosterLabel(j.hoster); - const td3 = document.createElement('td'); td3.textContent = statusLabel(j.status); - const td4 = document.createElement('td'); - const bg = document.createElement('span'); bg.className = 'progress-bar-bg'; - const fill = document.createElement('span'); fill.className = 'progress-bar-fill status-' + j.status; fill.style.width = pct + '%'; - bg.appendChild(fill); - const pctSpan = document.createElement('span'); pctSpan.className = 'progress-pct'; pctSpan.textContent = pct + '%'; - td4.appendChild(bg); td4.appendChild(pctSpan); - const td5 = document.createElement('td'); - td5.textContent = j.speedKbs ? (j.speedKbs > 1024 ? (j.speedKbs/1024).toFixed(1) + ' MB/s' : j.speedKbs + ' KB/s') : ''; - const td6 = document.createElement('td'); - if (link) { - const a = document.createElement('a'); a.href = link; a.target = '_blank'; a.rel = 'noreferrer'; a.textContent = link; - td6.appendChild(a); - } - tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); - return tr; -} + hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.'; -function buildPreviewRow(f, h) { - const tr = document.createElement('tr'); - tr.className = 'queue-row status-preview'; - ['name','hoster','Vorschau','','',''].forEach(function () {}); - const td1 = document.createElement('td'); td1.textContent = f.name; - const td2 = document.createElement('td'); td2.textContent = hosterLabel(h); - const td3 = document.createElement('td'); td3.textContent = 'Vorschau'; - const td4 = document.createElement('td'); - const td5 = document.createElement('td'); - const td6 = document.createElement('td'); - tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); - return tr; -} - -// Start batch -$('startBtn').addEventListener('click', async function () { - if (!selectedFiles.length || !selectedHosters.length) return; - const jobs = []; - queueJobs = []; - jobById.clear(); - selectedFiles.forEach(function (file) { - selectedHosters.forEach(function (hoster) { - const accounts = config.hosters[hoster] || []; - const primary = accounts.find(function (a) { return a.enabled && hasCreds(hoster, a); }); - if (!primary) return; - const job = { - id: uuid(), - upload_id: uuid(), - file: file.path, - file_name: file.name, - hoster: hoster, - account_id: primary.id, - username: primary.username || '', - password: primary.password || '', - api_key: primary.api_key || '', - max_attempts: 3, - }; - jobs.push(job); - const jobCopy = Object.assign({}, job, { fileName: job.file_name, status: 'queued', progress: 0 }); - queueJobs.push(jobCopy); - jobById.set(job.id, jobCopy); + list.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.addEventListener('change', () => { + input.closest('.hoster-option')?.classList.toggle('selected', input.checked); }); }); - if (!jobs.length) { showToast('Keine gültigen Jobs — prüfe Accounts'); return; } +} - uploading = true; - updateStartBtn(); - $('cancelBtn').disabled = false; - renderQueuePreview(); +function openHosterModal() { + syncSelectedUploadHosters(); + renderHosterModal(); + document.getElementById('hosterModal').style.display = 'flex'; +} +function closeHosterModal() { + const modal = document.getElementById('hosterModal'); + if (modal) modal.style.display = 'none'; +} + +function applyHosterSelection() { + selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) + .map(input => input.dataset.hosterModal); + // Move pending files to selectedFiles on confirm + const pendingPaths = new Set(_pendingFiles.map(f => f.path)); + if (_pendingFiles.length > 0) { + selectedFiles.push(..._pendingFiles); + _pendingFiles = []; + } + renderHosterSummary(); + + // During an active upload, build preview jobs for the new files and inject + // them into the running batch immediately (otherwise they'd be lost on + // handleBatchDone via syncSelectedFilesFromQueue) + if (uploading && pendingPaths.size > 0) { + buildQueuePreview(); // creates 'preview' jobs for new files + const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file)); + if (newJobs.length > 0) { + newJobs.forEach(j => { j.status = 'queued'; }); + renderQueueTable(); + window.api.addJobsToBatch({ + jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) + }).then(result => { _markSkippedJobs(result); }).catch(() => {}); + persistQueueStateSoon(true); + } + } + + updateUploadView(); + persistQueueStateSoon(true); // immediate persist after adding files + document.getElementById('hosterModal').style.display = 'none'; +} + +function cancelHosterModal() { + _pendingFiles = []; + closeHosterModal(); +} + +function normalizeRestoredJobStatus(status) { + if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status; + return 'queued'; +} + +function restoreQueueStateFromConfig() { + if (config?.globalSettings?.resumeQueueOnLaunch === false) return; + const pending = config?.globalSettings?.pendingQueue; + if (!pending || typeof pending !== 'object') return; + + selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) + ? pending.selectedUploadHosters.filter(Boolean) + : selectedUploadHosters; + + selectedFiles = Array.isArray(pending.selectedFiles) + ? pending.selectedFiles + .filter(file => file && file.path) + .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) + : []; + + const rawJobs = Array.isArray(pending.queueJobs) + ? pending.queueJobs + .filter(job => job && job.fileName && job.hoster) + .map(job => ({ + id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + uploadId: null, + file: job.file || '', + fileName: job.fileName, + hoster: job.hoster, + status: normalizeRestoredJobStatus(job.status), + bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, + bytesTotal: job.bytesTotal || 0, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: job.error || null, + result: job.result || null, + attempt: 0, + maxAttempts: job.maxAttempts || 0, + link: '', + progress: job.status === 'done' ? 1 : 0 + })) + : []; + + // Deduplicate: keep the job with the best status for each file+hoster pair + const seen = new Map(); + const statusPriority = { done: 0, uploading: 1, queued: 2, preview: 3, error: 4, aborted: 5, skipped: 6 }; + for (const job of rawJobs) { + const key = `${job.file}|${job.hoster}`; + const existing = seen.get(key); + if (!existing || (statusPriority[job.status] ?? 9) < (statusPriority[existing.status] ?? 9)) { + seen.set(key, job); + } + } + queueJobs = Array.from(seen.values()); + rebuildJobIndex(); +} + +function buildPersistedQueueState() { + const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status)); + const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); + + for (const job of persistableJobs) { + if (job.file && !selectedFileMap.has(job.file)) { + selectedFileMap.set(job.file, { + path: job.file, + name: job.fileName, + size: job.bytesTotal || 0 + }); + } + } + + if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { + return null; + } + + return { + selectedUploadHosters: getSelectedHosters(), + selectedFiles: Array.from(selectedFileMap.values()), + queueJobs: queueJobs.map(job => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster, + // Save aborted jobs as queued so they survive restart + status: job.status === 'aborted' ? 'queued' : job.status, + bytesTotal: job.bytesTotal || 0, + error: job.status === 'aborted' ? null : (job.error || null), + result: job.result || null, + maxAttempts: job.maxAttempts || 0 + })) + }; +} + +async function persistQueueStateNow() { + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: buildPersistedQueueState() + }; + config.globalSettings = globalSettings; + await window.api.saveGlobalSettings(globalSettings); +} + +function persistQueueStateSoon(immediate) { + clearTimeout(queuePersistTimer); + if (immediate) { + persistQueueStateNow().catch(() => {}); + return; + } + // Use longer debounce during uploads to reduce disk I/O + const delay = uploading ? 10000 : 500; + queuePersistTimer = setTimeout(() => { + persistQueueStateNow().catch(() => {}); + }, delay); +} + +function clearPersistedQueueStateSoon() { + clearTimeout(queuePersistTimer); + queuePersistTimer = setTimeout(() => { + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: null + }; + config.globalSettings = globalSettings; + window.api.saveGlobalSettings(globalSettings).catch(() => {}); + }, 0); +} + +// --- File selection --- +function setupDragDrop() { + const dropZone = document.getElementById('dropZone'); + // Allow drop on the entire upload view + const uploadView = document.getElementById('upload-view'); + let _dragCounter = 0; + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }); + dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); }); + dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over'); + addDroppedFiles(e.dataTransfer.files).catch(console.error); + }); + dropZone.addEventListener('click', () => pickFiles()); + + // Also handle drops on queue container + uploadView.addEventListener('dragover', (e) => { e.preventDefault(); }); + uploadView.addEventListener('drop', (e) => { + e.preventDefault(); + if (e.target.closest('.drop-zone')) return; // handled above + addDroppedFiles(e.dataTransfer.files).catch(console.error); + }); +} + +let _pendingFiles = []; // Files waiting for hoster modal confirmation + +let _addingDropped = false; + +async function addDroppedFiles(fileList) { + if (_addingDropped) return; + _addingDropped = true; try { - await invoke('start_batch', { - payload: { - jobs: jobs, - hoster_settings: config.hosterSettings || {}, - global_settings: config.globalSettings || {}, - accounts: config.hosters, - }, - }); - showToast('Batch abgeschlossen'); - } catch (err) { - showToast('Batch-Fehler: ' + err); + const files = Array.from(fileList); + const existingPaths = new Set([ + ...selectedFiles.map(f => f.path), + ..._pendingFiles.map(f => f.path) + ]); + const newFiles = []; + + for (const file of files) { + let filePath = ''; + try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; } + if (!filePath) continue; + + // Detect folders: directories report size 0 and empty type in Electron drag-and-drop + if (file.type === '' && file.size === 0) { + try { + const folderFiles = await window.api.resolveFolderFiles(filePath); + if (folderFiles && folderFiles.length > 0) { + for (const fp of folderFiles) { + if (!existingPaths.has(fp)) { + const name = fp.split('\\').pop().split('/').pop(); + newFiles.push({ path: fp, name, size: null }); + existingPaths.add(fp); + } + } + continue; + } + } catch {} + } + + // Regular file + const fileName = file.name || ''; + if (!existingPaths.has(filePath)) { + newFiles.push({ path: filePath, name: fileName, size: file.size }); + existingPaths.add(filePath); + } + } + + if (newFiles.length > 0) { + _pendingFiles.push(...newFiles); + openHosterModal(); + } } finally { - uploading = false; - updateStartBtn(); - $('cancelBtn').disabled = true; + _addingDropped = false; + } +} + +async function pickFiles() { + const paths = await window.api.selectFiles(); + if (!paths) return; + addPathsToQueue(paths); +} + +async function pickFolder() { + const paths = await window.api.selectFolder(); + if (!paths) return; + addPathsToQueue(paths); +} + +function addPathsToQueue(paths) { + // Build path-Set once so dedup is O(1) per candidate instead of O(n+m). + // Matters when the user picks a folder with thousands of files. + const existing = new Set(); + for (const f of selectedFiles) existing.add(f.path); + for (const f of _pendingFiles) existing.add(f.path); + + const newFiles = []; + for (const p of paths) { + if (existing.has(p)) continue; + existing.add(p); + const name = p.split('\\').pop().split('/').pop(); + newFiles.push({ path: p, name, size: null }); + } + if (newFiles.length > 0) { + _pendingFiles.push(...newFiles); + openHosterModal(); + } +} + +function updateUploadView() { + const dropZone = document.getElementById('dropZone'); + const queueShell = document.getElementById('queueShell'); + const queueActions = document.getElementById('queueActions'); + + if (selectedFiles.length === 0 && queueJobs.length === 0) { + dropZone.style.display = 'flex'; + queueShell.style.display = 'none'; + queueActions.style.display = 'none'; + } else { + dropZone.style.display = 'none'; + queueShell.style.display = 'flex'; + queueActions.style.display = 'flex'; + if (!uploading && selectedFiles.length > 0) { + buildQueuePreview(); + } + } + updateQueueActionButtons(); +} + +function updateStartButton() { + const btn = document.getElementById('startUploadBtn'); + const hosters = getSelectedHosters(); + const hasQueuedJobs = queueJobs.some(isStartableQueueJob); + const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0; + btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection); +} + +const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']); +const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']); + +function updateQueueActionButtons() { + updateStartButton(); + + const hasSelection = selectedJobIds.size > 0; + // Single pass over the (usually small) selection set instead of three O(n) + // scans over the entire queue. For 1000 jobs × 3 scans this drops the + // selection-change cost from ~3000 checks to |selection|. + let hasUploadSelection = false, hasAbortSelection = false, hasStartableSelection = false; + for (const id of selectedJobIds) { + const job = _jobIndexById.get(id); + if (!job) continue; + const s = job.status; + if (!hasUploadSelection && _UPLOAD_SELECTION_STATUSES.has(s)) hasUploadSelection = true; + if (!hasAbortSelection && _ABORT_SELECTION_STATUSES.has(s)) hasAbortSelection = true; + if (!hasStartableSelection && isStartableQueueStatus(s)) hasStartableSelection = true; + if (hasUploadSelection && hasAbortSelection && hasStartableSelection) break; + } + const hasMovableSelection = hasSelection && !uploading; + + const startSelectedBtn = document.getElementById('startSelectedBtn'); + const reuploadBtn = document.getElementById('reuploadSelectedBtn'); + const abortSelectedBtn = document.getElementById('abortSelectedBtn'); + const finishStopBtn = document.getElementById('finishStopBtn'); + const abortAllBtn = document.getElementById('abortAllBtn'); + const moveTopBtn = document.getElementById('moveTopBtn'); + const moveUpBtn = document.getElementById('moveUpBtn'); + const moveDownBtn = document.getElementById('moveDownBtn'); + const moveBottomBtn = document.getElementById('moveBottomBtn'); + + if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection; + if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection; + if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection; + if (finishStopBtn) finishStopBtn.disabled = !uploading; + if (abortAllBtn) abortAllBtn.disabled = !uploading; + if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection; + if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection; + if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection; + if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; +} + +// Build preview jobs from selected files x selected hosters (before upload starts) +function buildQueuePreview() { + const hosters = getSelectedHosters(); + if (hosters.length === 0) { + queueJobs = queueJobs.filter(j => j.status !== 'preview'); + rebuildJobIndex(); + renderQueueTable(); + persistQueueStateSoon(); + return; + } + // Remove old preview jobs + queueJobs = queueJobs.filter(j => j.status !== 'preview'); + + // Build a Set for fast existence checks + const existingKeys = new Set(); + for (const j of queueJobs) { + if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`); + } + + for (const file of selectedFiles) { + for (const hoster of hosters) { + const key = `${file.path}|${hoster}`; + if (!existingKeys.has(key) && !_completedUploadKeys.has(key)) { + const job = { + id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + file: file.path, fileName: file.name, hoster, + status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0, + speedKbs: 0, elapsed: 0, remaining: 0, + error: null, result: null, attempt: 0, maxAttempts: 0, link: '' + }; + queueJobs.push(job); + existingKeys.add(key); + } + } + } + rebuildJobIndex(); + renderQueueTable(); + persistQueueStateSoon(); +} + +// --- Job Index Management --- +function rebuildJobIndex() { + _jobIndexById.clear(); + _jobIndexByUploadId.clear(); + for (const job of queueJobs) { + _jobIndexById.set(job.id, job); + if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); + } +} + +function indexJob(job) { + _jobIndexById.set(job.id, job); + if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job); +} + +function removeJobFromIndex(job) { + _jobIndexById.delete(job.id); + if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId); + // Track deletion so handleProgress() won't re-create this job from stale callbacks + _deletedJobIds.add(job.id); + if (job.uploadId) _deletedJobIds.add(job.uploadId); + // Allow re-uploading same file+hoster after deletion + if (job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`); +} + +// --- Queue Table Rendering (debounced with virtual scrolling) --- +let _renderQueued = false; +let _sortedJobsCache = []; +const VIRTUAL_ROW_HEIGHT = 28; +const VIRTUAL_OVERSCAN = 10; +let _lastVisibleRange = { start: -1, end: -1 }; +let _queueListenersBound = false; + +// Throttled UI update scheduling – max one render per 200ms during uploads +let _uiUpdateTimer = null; +const UI_UPDATE_INTERVAL = 200; // ms + +function scheduleQueueRender() { + if (_renderQueued) return; + _renderQueued = true; + requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); +} + +let _recentRenderQueued = false; +function scheduleRecentRender() { + if (_recentRenderQueued) return; + _recentRenderQueued = true; + requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); }); +} + +// Toggle the .selected class on existing rows without rebuilding the table. +// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort). +function applyQueueSelectionClasses() { + const tbody = document.getElementById('queueBody'); + if (!tbody) return; + const rows = tbody.querySelectorAll('.queue-row'); + for (const tr of rows) { + tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId)); + } +} + +function applyRecentSelectionClasses() { + const tbody = document.getElementById('recentFilesBody'); + if (!tbody) return; + const rows = tbody.querySelectorAll('.recent-file-row'); + for (const tr of rows) { + const order = parseInt(tr.dataset.order, 10); + tr.classList.toggle('selected', selectedRecentIds.has(order)); + } +} + +function scheduleThrottledUIUpdate() { + if (_uiUpdateTimer) return; + _uiUpdateTimer = setTimeout(() => { + _uiUpdateTimer = null; + scheduleQueueRender(); + updateQueueActionButtons(); + updateStatusBar(); + updateStatsPanel(); + }, UI_UPDATE_INTERVAL); +} + +function buildRowHtml(job) { + const statusClass = `status-${job.status}`; + const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; + const uploadedSize = job.status === 'preview' + ? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') + : `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; + const statusText = getStatusText(job); + const elapsed = formatTime(job.elapsed); + const remaining = formatTime(job.remaining); + const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; + const pct = Math.min(100, Math.round((job.progress || 0) * 100)); + const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; + + return ` + ${escapeHtml(job.fileName)} + ${uploadedSize} + ${escapeHtml(job.hoster)} + ${escapeHtml(statusText)} + ${elapsed} + ${remaining} + ${speed} + +
+
+
+
+ ${job.status === 'preview' ? '' : pct + '%'} +
+ + `; +} + +// In-place update of a single row's cells (avoids full innerHTML rebuild) +function _updateRowInPlace(tr, job) { + const statusClass = `status-${job.status}`; + const uploadedSize = job.status === 'preview' + ? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...') + : `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`; + const statusText = getStatusText(job); + const elapsed = formatTime(job.elapsed); + const remaining = formatTime(job.remaining); + const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : ''; + const pct = Math.min(100, Math.round((job.progress || 0) * 100)); + const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; + + // Write DOM only when the target value actually changes — a no-op progress + // tick (same pct, same speed) then performs zero DOM work. Massive saver + // when most of the visible jobs are idle/queued/done and only a few are + // actively uploading. + const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`; + if (tr.className !== newClass) tr.className = newClass; + if (tr.dataset.link !== link) tr.dataset.link = link; + + const cells = tr.children; + if (cells.length < 8) return false; // structure mismatch, needs full rebuild + + if (cells[1].textContent !== uploadedSize) cells[1].textContent = uploadedSize; + // cells[0] (filename) and cells[2] (hoster) don't change during upload + const badge = cells[3].querySelector('.status-badge'); + if (badge) { + const badgeClass = `status-badge ${statusClass}`; + if (badge.className !== badgeClass) badge.className = badgeClass; + if (badge.textContent !== statusText) badge.textContent = statusText; + } + if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed; + if (cells[5].textContent !== remaining) cells[5].textContent = remaining; + if (cells[6].textContent !== speed) cells[6].textContent = speed; + + const fill = cells[7].querySelector('.progress-bar-fill'); + if (fill) { + const pctStr = pct + '%'; + if (fill.style.width !== pctStr) fill.style.width = pctStr; + const fillClass = `progress-bar-fill ${statusClass}`; + if (fill.className !== fillClass) fill.className = fillClass; + } + const pctSpan = cells[7].querySelector('.progress-pct'); + if (pctSpan) { + const pctText = job.status === 'preview' ? '' : pct + '%'; + if (pctSpan.textContent !== pctText) pctSpan.textContent = pctText; + } + + return true; +} + +function renderQueueTable() { + const tbody = document.getElementById('queueBody'); + if (!tbody) return; + + _sortedJobsCache = sortQueueJobs(queueJobs); + const totalRows = _sortedJobsCache.length; + + if (totalRows < 200) { + // Try in-place update if row count matches (fast path) + const existingRows = tbody.querySelectorAll('.queue-row'); + if (existingRows.length === totalRows && totalRows > 0) { + // In-place update – no DOM destruction + for (let i = 0; i < totalRows; i++) { + const tr = existingRows[i]; + const job = _sortedJobsCache[i]; + // If row identity changed (different job), fall back to full rebuild + if (tr.dataset.jobId !== job.id) { + tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); + _lastVisibleRange = { start: -1, end: -1 }; + break; + } + _updateRowInPlace(tr, job); + } + } else { + // Full rebuild needed (row count changed) + tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join(''); + _lastVisibleRange = { start: -1, end: -1 }; + } + } else { + // Virtual scrolling for large queues — in-place update when range unchanged + _renderVirtualRows(tbody); + } + + // Bind event delegation once + if (!_queueListenersBound) { + _queueListenersBound = true; + tbody.addEventListener('click', (e) => { + const row = e.target.closest('.queue-row'); + if (row) handleRowClick(e, row); + }); + tbody.addEventListener('contextmenu', (e) => { + const row = e.target.closest('.queue-row'); + if (row) handleRowContextMenu(e, row); + }); + } + + // Update retry button visibility + const hasFailedJobs = queueJobs.some(j => j.status === 'error'); + document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; + updateQueueActionButtons(); +} + +function _renderVirtualRows(tbody) { + const scrollContainer = document.getElementById('queueContainer'); + if (!scrollContainer) return; + + const totalRows = _sortedJobsCache.length; + const scrollTop = scrollContainer.scrollTop; + // Use a minimum viewport height to avoid rendering nothing when container is being laid out + const viewportHeight = Math.max(scrollContainer.clientHeight, 200); + + const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN); + const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN); + + // Same range — try in-place update to avoid hover flicker + if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) { + const rows = tbody.querySelectorAll('.queue-row'); + if (rows.length === endIdx - startIdx) { + let allMatch = true; + for (let i = 0; i < rows.length; i++) { + const job = _sortedJobsCache[startIdx + i]; + if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; } + _updateRowInPlace(rows[i], job); + } + if (allMatch) return; // all rows updated in-place, no rebuild needed + } + } + _lastVisibleRange = { start: startIdx, end: endIdx }; + + const topPad = startIdx * VIRTUAL_ROW_HEIGHT; + const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT); + + let html = ''; + if (topPad > 0) html += ``; + for (let i = startIdx; i < endIdx; i++) { + html += buildRowHtml(_sortedJobsCache[i]); + } + if (bottomPad > 0) html += ``; + + tbody.innerHTML = html; +} + +// Coalesce rapid scroll events (a fast trackpad fling fires dozens) into one +// render per frame. rAF keeps the scroll thread cheap. +let _queueScrollQueued = false; +function _onQueueScroll() { + if (_queueScrollQueued) return; + if (_sortedJobsCache.length < 200) return; + _queueScrollQueued = true; + requestAnimationFrame(() => { + _queueScrollQueued = false; + const tbody = document.getElementById('queueBody'); + if (tbody) _renderVirtualRows(tbody); + }); +} + +const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); +const _collatorSimple = new Intl.Collator('de'); + +// Queue sort memoization. Keys that don't change after a job enters the queue +// (filename, host) reuse the cached result across progress-driven re-renders. +// Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when +// previews resolve / upload starts) are recomputed each call — otherwise a +// queue sorted by size during previews would be stuck in all-zeros order. +let _queueSortCache = { sig: '', result: [] }; +const _STATIC_SORT_KEYS = new Set(['filename', 'host']); + +function sortQueueJobs(jobs) { + const { key, direction } = queueSortState; + const factor = direction === 'asc' ? 1 : -1; + const canCache = _STATIC_SORT_KEYS.has(key); + const sig = canCache ? `${key}|${direction}|${jobs.length}` : ''; + if (sig && _queueSortCache.sig === sig) return _queueSortCache.result; + + const sorted = jobs.slice().sort((a, b) => { + let cmp = 0; + if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName); + else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0); + else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, b.hoster); + else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status); + else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0); + else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0); + return cmp * factor; + }); + if (sig) _queueSortCache = { sig, result: sorted }; + return sorted; +} + +function getStatusOrder(status) { + const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 }; + return order[status] ?? 4; +} + +function getStatusText(job) { + switch (job.status) { + case 'preview': return 'Bereit'; + case 'queued': return 'Wartet'; + case 'getting-server': return 'Server...'; + case 'uploading': return 'Upload'; + case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`; + case 'done': return 'Fertig'; + case 'aborted': return 'Abgebrochen'; + case 'error': return 'Fehlgeschlagen'; + case 'skipped': return 'Übersprungen'; + default: return job.status; + } +} + +// --- Queue interactions --- +function handleRowClick(e, row) { + const jobId = row.dataset.jobId; + // Clear recent panel selection when clicking in queue — class-toggle only. + if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); } + + if (e.ctrlKey || e.metaKey) { + if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); + else selectedJobIds.add(jobId); + } else if (e.shiftKey && selectedJobIds.size > 0) { + // Use sorted jobs cache for correct shift-click with virtual scrolling + const sortedIds = _sortedJobsCache.map(j => j.id); + const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id)); + const curIdx = sortedIds.indexOf(jobId); + if (lastIdx >= 0 && curIdx >= 0) { + const from = Math.min(lastIdx, curIdx); + const to = Math.max(lastIdx, curIdx); + for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]); + } + } else { + selectedJobIds.clear(); + selectedJobIds.add(jobId); + // Single click on done job -> copy link + const job = _jobIndexById.get(jobId); + if (job && job.status === 'done' && job.result) { + const link = job.result.download_url || job.result.embed_url || ''; + if (link) { + window.api.copyToClipboard(link); + showCopyToast('Link kopiert'); + } + } + } + // Selection changes don't change sort order / row content — just toggle classes. + applyQueueSelectionClasses(); + updateQueueActionButtons(); +} + +// --- Context menu --- +let alwaysOnTopState = false; + +// Cache hoster-counts for the context menu. Invalidated on structural changes +// to queueJobs (the length-based signature is good enough — a job's hoster +// never changes after it's created). +let _hosterCountsCache = { sig: '', result: new Map() }; +function _getHosterCounts() { + const sig = `${queueJobs.length}`; + if (_hosterCountsCache.sig === sig) return _hosterCountsCache.result; + const m = new Map(); + for (let i = 0; i < queueJobs.length; i++) { + const h = queueJobs[i].hoster; + m.set(h, (m.get(h) || 0) + 1); + } + _hosterCountsCache = { sig, result: m }; + return m; +} + +function handleRowContextMenu(e, row) { + e.preventDefault(); + const jobId = row.dataset.jobId; + if (!selectedJobIds.has(jobId)) { + selectedJobIds.clear(); + selectedJobIds.add(jobId); + applyQueueSelectionClasses(); + updateQueueActionButtons(); + } + showContextMenu(e.clientX, e.clientY); +} + +function showContextMenu(x, y) { + const menu = document.getElementById('contextMenu'); + // Update "Always on top" text + const aotItem = menu.querySelector('[data-action="always-on-top"]'); + if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund'; + // Update labels with selection count + const n = selectedJobIds.size; + const delItem = menu.querySelector('[data-action="delete-selected"]'); + if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen'; + const copyItem = menu.querySelector('[data-action="copy-links"]'); + if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren'; + menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => { + el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen'; + }); + const startItem = menu.querySelector('[data-action="start-selected"]'); + if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten'; + + // Dynamic "delete by hoster" submenu — cached count keyed by queue length + // so a right-click on a 5000-job queue doesn't rescan everything. + const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu'); + const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items'); + const hosterCounts = _getHosterCounts(); + deleteHosterContainer.innerHTML = ''; + if (hosterCounts.size > 0) { + deleteHosterSubmenu.style.display = ''; + hosterCounts.forEach((count, hoster) => { + const item = document.createElement('div'); + item.className = 'ctx-item ctx-item-danger'; + item.dataset.action = `delete-hoster:${hoster}`; + item.textContent = `${getHosterLabel(hoster)} (${count})`; + deleteHosterContainer.appendChild(item); + }); + } else { + deleteHosterSubmenu.style.display = 'none'; + } + + menu.style.display = 'block'; + const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5); + menu.style.left = menuX + 'px'; + menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; + + // Flip submenus if they would overflow the viewport right edge + menu.querySelectorAll('.ctx-submenu-items').forEach(sub => { + // Temporarily show to measure actual width (display:none → offsetWidth=0) + sub.style.visibility = 'hidden'; + sub.style.display = 'block'; + sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth); + sub.style.display = ''; + sub.style.visibility = ''; + }); +} + +function hideContextMenu() { + document.getElementById('contextMenu').style.display = 'none'; + document.getElementById('recentContextMenu').style.display = 'none'; +} + +function deleteSelectedRecentFiles() { + if (selectedRecentIds.size === 0) return; + let removedDone = 0, removedErr = 0; + sessionFilesData = sessionFilesData.filter(r => { + if (!selectedRecentIds.has(r.order)) return true; + if (r.isError) removedErr++; else removedDone++; + _sessionFileKeys.delete(`${r.link}\u0001${r.filename}\u0001${r.host}`); + return false; + }); + _sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone); + _sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr); + selectedRecentIds.clear(); + renderRecentUploadsPanel(); +} + +function clearAllRecentFiles() { + if (sessionFilesData.length === 0) return; + if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return; + sessionFilesData = []; + _sessionFileKeys.clear(); + _sessionDoneCount = 0; + _sessionErrorCount = 0; + selectedRecentIds.clear(); + renderRecentUploadsPanel(); +} + +async function exportAllRecentFiles() { + if (sessionFilesData.length === 0) { + alert('Keine Einträge zum Exportieren.'); + return; + } + const rows = sortRecentFiles(sessionFilesData); + const header = 'timestamp|hoster|link|filename|status'; + const lines = rows.map(r => { + const ts = r.timestamp || r.time || ''; + const host = r.host || r.hoster || ''; + const link = r.link || ''; + const name = r.filename || ''; + const status = r.isError ? 'error' : 'ok'; + return [ts, host, link, name, status].map(v => String(v).replace(/[\r\n|]/g, ' ')).join('|'); + }); + const content = [header, ...lines].join('\n') + '\n'; + const defaultName = `uploads-${new Date().toISOString().slice(0, 10)}.log`; + try { + const result = await window.api.saveTextFile(defaultName, content, [ + { name: 'Log-Datei', extensions: ['log', 'txt', 'csv'] } + ]); + if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`); + } catch (err) { + alert('Export fehlgeschlagen: ' + (err.message || err)); + } +} + +function copySelectedRecentLinks() { + const links = sessionFilesData + .filter(r => selectedRecentIds.has(r.order) && !r.isError) + .map(r => r.link) + .filter(Boolean); + if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } +} + +// --- Backup export / import --- +async function doBackupExport() { + try { + const result = await window.api.exportBackup(); + if (result && result.ok) showCopyToast('Backup exportiert'); + } catch (err) { + alert('Export fehlgeschlagen: ' + (err.message || err)); + } +} + +function askLegacyBackupPassword() { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + + const card = document.createElement('div'); + card.className = 'modal-card'; + card.style.width = 'min(380px,100%)'; + + const header = document.createElement('div'); + header.className = 'modal-header'; + const h3 = document.createElement('h3'); + h3.textContent = 'Passwort erforderlich'; + header.appendChild(h3); + + const body = document.createElement('div'); + body.className = 'modal-body'; + const p = document.createElement('p'); + p.style.margin = '0 0 10px'; + p.style.fontSize = '13px'; + p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.'; + const input = document.createElement('input'); + input.type = 'password'; + input.className = 'key-input'; + input.placeholder = 'Passwort'; + input.autocomplete = 'off'; + input.style.width = '100%'; + body.appendChild(p); + body.appendChild(input); + + const footer = document.createElement('div'); + footer.className = 'modal-footer'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-secondary'; + cancelBtn.textContent = 'Abbrechen'; + const okBtn = document.createElement('button'); + okBtn.className = 'btn btn-primary'; + okBtn.textContent = 'Importieren'; + footer.appendChild(cancelBtn); + footer.appendChild(okBtn); + + card.appendChild(header); + card.appendChild(body); + card.appendChild(footer); + overlay.appendChild(card); + document.body.appendChild(overlay); + + const done = (val) => { overlay.remove(); resolve(val); }; + okBtn.onclick = () => done(input.value || null); + cancelBtn.onclick = () => done(null); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') done(input.value || null); + if (e.key === 'Escape') done(null); + }); + input.focus(); + }); +} + +async function doBackupImport(legacyPassword) { + const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined; + try { + const result = await window.api.importBackup(pw); + if (!result || result.canceled) return; + if (result.needsPassword) { + const entered = await askLegacyBackupPassword(); + if (entered) doBackupImport(entered); + return; + } + if (result.ok) { + config = result.config; + hosterSettings = config.hosterSettings || {}; + alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); + window.api.setAlwaysOnTop(alwaysOnTopState); + renderSettings(); + renderAccounts(); + renderHosterSummary(); + renderHosterModal(); + loadHistory(); + showCopyToast('Backup importiert'); + } else if (result.error) { + alert('Import fehlgeschlagen: ' + result.error); + } + } catch (err) { + alert('Import fehlgeschlagen: ' + (err.message || err)); + } +} + +document.addEventListener('click', (e) => { + if (!e.target.closest('.context-menu')) hideContextMenu(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + hideContextMenu(); + cancelHosterModal(); + } + if (e.target.closest('input, textarea, select')) return; + const activeView = document.querySelector('.view.active'); + // Ctrl+A + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + if (activeView && activeView.id === 'upload-view') { + e.preventDefault(); + // Select recent files only if user's last interaction was in the recent panel + if (selectedRecentIds.size > 0 && selectedJobIds.size === 0) { + sessionFilesData.forEach(r => selectedRecentIds.add(r.order)); + renderRecentUploadsPanel(); + } else if (queueJobs.length > 0) { + queueJobs.forEach(j => selectedJobIds.add(j.id)); + renderQueueTable(); + } + } + } + // Delete + if (e.key === 'Delete') { + if (activeView && activeView.id === 'upload-view') { + e.preventDefault(); + if (selectedRecentIds.size > 0) { + deleteSelectedRecentFiles(); + } else if (selectedJobIds.size > 0) { + const deletedIds = [...selectedJobIds]; + // Cancel active uploads for deleted jobs + const activeIds = deletedIds.filter(id => { + const j = _jobIndexById.get(id); + return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); + }); + if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); + queueJobs = queueJobs.filter(j => { + if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } + return true; + }); + selectedJobIds.clear(); + syncSelectedFilesFromQueue(); + renderQueueTable(); + if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + updateStatusBar(); + persistQueueStateSoon(true); + } + } } }); -$('cancelBtn').addEventListener('click', async function () { - await invoke('cancel_batch'); - showToast('Abgebrochen'); +document.getElementById('contextMenu').addEventListener('click', (e) => { + const item = e.target.closest('.ctx-item'); + if (!item) return; + const action = item.dataset.action; + if (!action) return; + hideContextMenu(); + handleContextAction(action); }); -// Events -listen('upload-progress', function (ev) { - const p = ev.payload; - const job = jobById.get(p.jobId); - if (!job) return; - job.status = p.status; - job.progress = p.progress; - job.speedKbs = p.speedKbs; - job.bytesUploaded = p.bytesUploaded; - job.bytesTotal = p.bytesTotal; - if (p.result) job.result = p.result; - if (p.error) job.error = p.error; - renderQueuePreview(); - updateQueueStats(); -}); - -listen('upload-stats', function (ev) { - const s = ev.payload; - $('queueStats').textContent = - 'Aktiv: ' + s.activeJobs + ' | Wartet: ' + s.pendingJobs + ' | ' + - (s.globalSpeedKbs/1024).toFixed(1) + ' MB/s'; -}); - -listen('upload-batch-done', function (ev) { - const s = ev.payload; - uploading = false; - updateStartBtn(); - $('cancelBtn').disabled = true; - showToast('Batch fertig: ' + s.succeeded + '/' + s.total + ' erfolgreich'); -}); - -listen('account-rotation-log', function (ev) { - const p = ev.payload; - const el = $('rotLog'); - const extras = Object.keys(p).filter(function (k) { return k !== 'ts' && k !== 'event'; }) - .map(function (k) { return k + '=' + (typeof p[k] === 'string' ? p[k] : JSON.stringify(p[k])); }).join(' '); - const line = '[' + new Date(p.ts).toISOString() + '] [' + p.event + '] ' + extras; - el.textContent = line + '\n' + el.textContent; - if (p.event === 'rotate') showToast(hosterLabel(p.hoster) + ': Account-Wechsel → Fallback'); - if (p.event === 'final-error') showToast(hosterLabel(p.hoster) + ': Alle Accounts ausgeschöpft'); -}); - -listen('account-switched', function (ev) { - const p = ev.payload; - showToast(hosterLabel(p.hoster) + ': ' + String(p.toAccountId).slice(-6) + ' aktiv'); -}); - -function updateQueueStats() { - const total = queueJobs.length; - const done = queueJobs.filter(function (j) { return j.status === 'done'; }).length; - const err = queueJobs.filter(function (j) { return j.status === 'error'; }).length; - $('queueStats').textContent = done + '/' + total + ' fertig' + (err ? (' • ' + err + ' Fehler') : ''); +async function handleContextAction(action) { + if (action === 'start-selected') { + startSelectedUpload(); + } else if (action === 'copy-links') { + const links = getSelectedJobLinks(); + if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } + } else if (action === 'retry-selected') { + retrySelectedJobs(); + } else if (action === 'delete-selected') { + // Cancel active uploads for deleted jobs + const activeIds = [...selectedJobIds].filter(id => { + const j = _jobIndexById.get(id); + return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); + }); + if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); + queueJobs = queueJobs.filter(j => { + if (selectedJobIds.has(j.id)) { + removeJobFromIndex(j); + return false; + } + return true; + }); + selectedJobIds.clear(); + syncSelectedFilesFromQueue(); + renderQueueTable(); + if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + updateStatusBar(); + persistQueueStateSoon(true); + } else if (action === 'copy-all-links') { + copyAllLinks(); + } else if (action === 'delete-all') { + // Cancel all active uploads + const activeIds = queueJobs + .filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server') + .map(j => j.id); + if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); + queueJobs.forEach(j => removeJobFromIndex(j)); + queueJobs = []; + selectedJobIds.clear(); + selectedFiles = []; + syncSelectedFilesFromQueue(); + renderQueueTable(); + updateUploadView(); + updateStatusBar(); + persistQueueStateSoon(true); + } else if (action === 'always-on-top') { + alwaysOnTopState = !alwaysOnTopState; + await window.api.setAlwaysOnTop(alwaysOnTopState); + } else if (action.startsWith('delete-hoster:')) { + const hoster = action.replace('delete-hoster:', ''); + // Cancel active uploads for this hoster + const activeIds = queueJobs + .filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview')) + .map(j => j.id); + if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); + // Remove ALL jobs for this hoster + queueJobs = queueJobs.filter(j => { + if (j.hoster === hoster) { removeJobFromIndex(j); return false; } + return true; + }); + selectedJobIds.clear(); + syncSelectedFilesFromQueue(); + renderQueueTable(); + if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + updateStatusBar(); + updateQueueActionButtons(); + persistQueueStateSoon(true); + } else if (action.startsWith('shutdown-')) { + const mode = action.replace('shutdown-', ''); + await window.api.setShutdownAfterFinish(mode); + } } -// Settings -function renderSettings() { - if (!config) return; - $('globalSpeed').value = config.globalSettings.globalMaxSpeedKbs || 0; - $('globalParallel').value = config.globalSettings.parallelUploadCount || 0; +function getSelectedJobLinks() { + return queueJobs + .filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result) + .map(j => j.result.download_url || j.result.embed_url || '') + .filter(Boolean); } -$('saveSettingsBtn').addEventListener('click', async function () { - config.globalSettings.globalMaxSpeedKbs = parseInt($('globalSpeed').value) || 0; - config.globalSettings.parallelUploadCount = parseInt($('globalParallel').value) || 0; - await invoke('save_config', { config: config }); - showToast('Gespeichert'); -}); +// --- Upload --- +async function startUpload() { + if (uploading) return; + uploading = true; // set immediately to prevent double-click race + updateQueueActionButtons(); + + const hosters = getSelectedHosters(); + if (queueJobs.length === 0 && selectedFiles.length > 0) { + if (hosters.length === 0) { + alert('Bitte mindestens einen Hoster auswählen.'); + uploading = false; + updateQueueActionButtons(); + return; + } + buildQueuePreview(); + } + + const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status)); + if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } -// Log -async function refreshLog() { try { - const content = await invoke('read_rotation_log'); - $('rotLog').textContent = content || '(noch keine Rotation-Events)'; - } catch (e) { $('rotLog').textContent = 'Fehler: ' + e; } -} -$('refreshLogBtn').addEventListener('click', refreshLog); -$('openLogFolderBtn').addEventListener('click', function () { invoke('open_log_folder').catch(function () {}); }); + jobsToStart.forEach(j => { + j.status = 'queued'; + j.error = null; + j.result = null; + j.bytesUploaded = 0; + j.speedKbs = 0; + j.elapsed = 0; + j.remaining = 0; + j.progress = 0; + j.uploadId = null; + }); + updateQueueActionButtons(); + renderQueueTable(); + updateStatusBar(); -// Init -loadConfig().catch(function (err) { - const div = document.createElement('div'); - div.style.padding = '20px'; - div.style.color = 'var(--danger)'; - div.textContent = 'Config-Fehler: ' + err; - document.body.insertBefore(div, document.body.firstChild); + const uploadPayload = { + hosters, + jobs: jobsToStart.map((job) => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster + })) + }; + const result = await window.api.startUpload(uploadPayload); + _markSkippedJobs(result); + persistQueueStateSoon(); + + if (result && result.error) { + alert(result.error); + uploading = false; + updateQueueActionButtons(); + updateStatusBar(); + } + } catch (err) { + uploading = false; + updateQueueActionButtons(); + updateStatusBar(); + alert(`Upload-Start fehlgeschlagen: ${err.message}`); + } +} + +function _markSkippedJobs(result) { + if (!result || !Array.isArray(result.skippedJobs) || result.skippedJobs.length === 0) return; + for (const skipped of result.skippedJobs) { + const job = _jobIndexById.get(skipped.jobId); + if (job) { + job.status = 'error'; + job.error = skipped.reason || 'Kein gültiger Account'; + } + } + renderQueueTable(); +} + +async function startSelectedUpload() { + if (uploading) { + // Batch already running — add selected jobs (queued/error/aborted/skipped) to running batch + // Upload-manager has duplicate protection (skips jobs already tracked) + const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && ['queued', 'error', 'aborted', 'skipped'].includes(j.status)); + if (addable.length > 0) { + addable.forEach(j => { + j.status = 'queued'; j.error = null; j.result = null; + j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; + }); + renderQueueTable(); + let result = null; + try { + result = await window.api.addJobsToBatch({ + jobs: addable.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster })) + }); + } catch (err) { + showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${err.message}`); + return; + } + + // If the batch ended between UI-state and IPC call, start a fresh batch immediately + if (result && result.error === 'Kein Upload aktiv') { + uploading = false; + updateQueueActionButtons(); + updateStatusBar(); + await startSelectedUpload(); + return; + } + _markSkippedJobs(result); + persistQueueStateSoon(); + const added = Number(result && result.added) || 0; + // Use ASCII-only toast text here to avoid encoding artifacts on some systems. + const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0; + const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds) + ? result.alreadyInBatchJobIds.length + : Math.max(0, addable.length - added - skipped); + const toastParts = []; + if (added > 0) toastParts.push(`${added} hinzugefuegt`); + if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`); + if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`); + if (result && result.error) { + showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${result.error}`); + } else if (toastParts.length > 0) { + showCopyToast(`Jobs: ${toastParts.join(', ')}`); + } else { + showCopyToast('Keine Jobs hinzugefuegt'); + } + return; + } + return; + } + uploading = true; // set immediately to prevent double-click race + updateQueueActionButtons(); + + const hosters = getSelectedHosters(); + const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status)); + if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } + + try { + jobsToStart.forEach(j => { + j.status = 'queued'; + j.error = null; + j.result = null; + j.bytesUploaded = 0; + j.speedKbs = 0; + j.progress = 0; + j.uploadId = null; + }); + updateQueueActionButtons(); + renderQueueTable(); + updateStatusBar(); + + const uploadPayload = { + hosters, + jobs: jobsToStart.map((job) => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster + })) + }; + const result = await window.api.startUpload(uploadPayload); + _markSkippedJobs(result); + persistQueueStateSoon(); + + if (result && result.error) { + alert(result.error); + uploading = false; + updateQueueActionButtons(); + updateStatusBar(); + } + } catch (err) { + uploading = false; + updateQueueActionButtons(); + updateStatusBar(); + alert(`Upload-Start fehlgeschlagen: ${err.message}`); + } +} + +async function cancelUpload() { + await window.api.cancelUpload(); + uploading = false; + // Reset all non-finished jobs back to queued state + for (const job of queueJobs) { + if (!['done', 'error', 'skipped'].includes(job.status)) { + job.status = 'queued'; + job.progress = 0; + job.bytesUploaded = 0; + job.speedKbs = 0; + job.elapsed = 0; + job.remaining = 0; + job.error = null; + } + } + renderQueueTable(); + updateQueueActionButtons(); + updateStatusBar(); + persistQueueStateSoon(); +} + +// --- Progress handling --- +function handleProgress(data) { + let job = data.jobId ? _jobIndexById.get(data.jobId) : null; + if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); + if (!job) { + job = queueJobs.find(j => + j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued' + ) || queueJobs.find(j => + j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview' + ); + if (job && data.uploadId) { + job.uploadId = data.uploadId; + _jobIndexByUploadId.set(data.uploadId, job); + } + } + if (!job) { + // Don't re-create jobs that were explicitly deleted by the user + if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) { + return; + } + job = { + id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + uploadId: data.uploadId, + file: '', fileName: data.fileName, hoster: data.hoster, + status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0, + speedKbs: 0, elapsed: 0, remaining: 0, + error: null, result: null, attempt: 0, maxAttempts: 0, link: '' + }; + queueJobs.push(job); + indexJob(job); + } + + // Don't regress from terminal states (stale callbacks can arrive after completion) + if (job.status === 'done' || job.status === 'skipped') return; + + // Update job state + job.status = data.status; + job.bytesUploaded = data.bytesUploaded || 0; + job.bytesTotal = data.bytesTotal || job.bytesTotal; + // Track session total bytes (survives removeFromQueueOnDone) + if (job.bytesTotal > 0 && !_sessionTrackedJobs.has(job.id)) { + _sessionTotalBytes += job.bytesTotal; + _sessionTrackedJobs.add(job.id); + } + job.speedKbs = data.speedKbs || 0; + job.elapsed = data.elapsed || 0; + job.remaining = data.remaining || 0; + job.error = data.error || null; + job.result = data.result || job.result; + job.attempt = data.attempt || 0; + job.maxAttempts = data.maxAttempts || 0; + job.progress = data.progress || 0; + if (data.uploadId) { + job.uploadId = data.uploadId; + _jobIndexByUploadId.set(data.uploadId, job); + } + + maybeAddSessionFile(job); + + // Track session uploaded bytes (survives removeFromQueueOnDone) + if (job.status === 'done' && !_sessionDoneJobs.has(job.id)) { + _sessionUploadedBytes += job.bytesTotal || 0; + _sessionDoneJobs.add(job.id); + } + + // Track completed uploads so they don't get re-queued after removal + if (job.status === 'done') { + _completedUploadKeys.add(`${job.file}|${job.hoster}`); + } + + // Remove finished jobs from queue immediately if setting is enabled + if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) { + removeJobFromIndex(job); + selectedJobIds.delete(job.id); + queueJobs = queueJobs.filter(j => j !== job); + } + + // Status changes (done/error/etc) get immediate render; ongoing progress is throttled + if (data.status === 'uploading') { + scheduleThrottledUIUpdate(); + } else { + scheduleQueueRender(); + updateQueueActionButtons(); + updateStatusBar(); + updateStatsPanel(); + } + persistQueueStateSoon(); +} + +function handleBatchDone(summary) { + uploading = false; + applySummaryResults(summary); + _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes + + // Reset aborted jobs back to queued so they can be restarted + for (const job of queueJobs) { + if (job.status === 'aborted') { + job.status = 'queued'; + job.progress = 0; + job.bytesUploaded = 0; + job.speedKbs = 0; + job.elapsed = 0; + job.remaining = 0; + job.error = null; + } + } + + syncSelectedFilesFromQueue(); + updateQueueActionButtons(); + renderQueueTable(); + renderRecentUploadsPanel(); + // History is only visible on the Verlauf tab. Mark it dirty and refresh when + // the user actually switches to it — skips an IPC + full table rebuild per + // batch-done when the user is watching the upload view. + _historyDirty = true; + if (_isHistoryTabActive()) loadHistory(); + + const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; + if (removeOnDone) { + // Single pass: build the keep-list and clean up the index for removed jobs. + const nextJobs = []; + for (const job of queueJobs) { + if (job.status === 'done') { + removeJobFromIndex(job); + selectedJobIds.delete(job.id); + } else { + nextJobs.push(job); + } + } + queueJobs = nextJobs; + renderQueueTable(); + } + + if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true); + else clearPersistedQueueStateSoon(); + + lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; + updateStatusBar(); +} + +function handleStats(data) { + lastUploadStats = { + state: data.state || 'idle', + globalSpeedKbs: data.globalSpeedKbs || 0, + totalBytes: data.totalBytes || 0, + elapsed: data.elapsed || 0, + activeJobs: data.activeJobs || 0 + }; + updateStatusBar(); + updateStatsPanel(); + + // Track run time + if (data.state === 'uploading' || data.state === 'stopping') { + if (!statsStartTime) { + statsStartTime = Date.now(); + statsRunTimer = setInterval(() => { + const el = document.getElementById('statRunTime'); + if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000)); + }, 1000); + } + } else if (data.state === 'idle' && statsRunTimer) { + clearInterval(statsRunTimer); + statsRunTimer = null; + } +} + +// --- Retry --- +async function retrySelectedJobs() { + const retryJobs = []; + // Build a Set for O(1) selectedFiles dedup below. + const existingFilePaths = new Set(); + for (const f of selectedFiles) existingFilePaths.add(f.path); + + queueJobs.forEach(j => { + if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) { + // Invalidate the old uploadId: retire the index entry and mark it so + // any late progress event from the previous (cancelled/completed) + // upload can't overwrite the freshly-reset state. + if (j.uploadId) { + _jobIndexByUploadId.delete(j.uploadId); + _deletedJobIds.add(j.uploadId); + } + j.status = uploading ? 'queued' : 'preview'; + j.error = null; + j.result = null; + j.bytesUploaded = 0; + j.speedKbs = 0; + j.elapsed = 0; + j.remaining = 0; + j.progress = 0; + j.uploadId = null; + retryJobs.push(j); + if (!existingFilePaths.has(j.file)) { + selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); + existingFilePaths.add(j.file); + } + } + }); + if (retryJobs.length === 0) return; + + // Select the retry jobs and start them immediately + selectedJobIds.clear(); + retryJobs.forEach(j => selectedJobIds.add(j.id)); + renderQueueTable(); + updateQueueActionButtons(); + updateStatusBar(); + persistQueueStateSoon(); + await startSelectedUpload(); +} + +async function abortSelectedJobs() { + const activeJobIds = []; + + queueJobs.forEach((job) => { + if (!selectedJobIds.has(job.id)) return; + + if (['preview', 'queued'].includes(job.status)) { + job.status = 'aborted'; + job.error = 'Abgebrochen'; + job.progress = 0; + job.uploadId = null; + } else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) { + activeJobIds.push(job.id); + } + }); + + if (activeJobIds.length > 0) { + await window.api.cancelSelectedJobs(activeJobIds); + } + + selectedJobIds.clear(); + syncSelectedFilesFromQueue(); + renderQueueTable(); + updateQueueActionButtons(); + updateStatusBar(); + persistQueueStateSoon(true); +} + +async function finishUploadsInProgress() { + if (!uploading) return; + await window.api.finishAfterActive(); + lastUploadStats.state = 'stopping'; + updateStatusBar(); +} + +async function abortAllUploads() { + await cancelUpload(); +} + +function moveSelectedJobs(direction) { + if (uploading || selectedJobIds.size === 0) return; + + const jobs = queueJobs.slice(); + + if (direction === 'top') { + queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id))); + } else if (direction === 'bottom') { + queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id))); + } else if (direction === 'up') { + for (let i = 1; i < jobs.length; i++) { + if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) { + [jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]]; + } + } + queueJobs = jobs; + } else if (direction === 'down') { + for (let i = jobs.length - 2; i >= 0; i--) { + if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) { + [jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]]; + } + } + queueJobs = jobs; + } + + rebuildJobIndex(); + renderQueueTable(); + updateStatusBar(); + persistQueueStateSoon(true); +} + +function syncSelectedFilesFromQueue() { + const fileMap = new Map(); + queueJobs + .filter((job) => !['done', 'skipped', 'aborted'].includes(job.status)) + .forEach((job) => { + if (!job.file || fileMap.has(job.file)) return; + fileMap.set(job.file, { + path: job.file, + name: job.fileName, + size: job.bytesTotal || 0 + }); + }); + selectedFiles = Array.from(fileMap.values()); +} + +function maybeAddSessionFile(job) { + if (!job) return; + + const dt = formatDateTime(new Date()); + if (job.status === 'done' && job.result) { + const link = job.result.download_url || job.result.embed_url || ''; + if (!link) return; + const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`; + if (!_sessionFileKeys.has(dedupKey)) { + _sessionFileKeys.add(dedupKey); + sessionFilesData.push({ + date: dt.text, + dateTs: dt.ts, + filename: job.fileName || '', + host: job.hoster || '', + link, + isError: false, + order: sessionFilesData.length + }); + _sessionDoneCount++; + // Coalesce rapid successive adds into one render per frame. + scheduleRecentRender(); + } + } + +} + +function applySummaryResults(summary) { + const files = Array.isArray(summary?.files) ? summary.files : []; + // Build a (fileName + hoster) → job map once so the per-result lookup is O(1) + // instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters) + // otherwise become O(n²). + const jobByKey = new Map(); + for (const j of queueJobs) { + jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j); + } + for (const file of files) { + for (const result of file.results || []) { + const job = jobByKey.get(`${file.name}\u0001${result.hoster}`); + if (!job) continue; + if (result.status === 'done') { + job.status = 'done'; + job.result = { + download_url: result.download_url || null, + embed_url: result.embed_url || null, + file_code: result.file_code || null + }; + job.error = null; + job.progress = 1; + job.bytesUploaded = job.bytesTotal || file.size || 0; + } else if (result.status === 'aborted') { + job.status = 'aborted'; + job.error = result.error || 'Abgebrochen'; + } else if (result.status === 'error') { + job.status = 'error'; + job.error = result.error || 'Fehlgeschlagen'; + } + maybeAddSessionFile(job); + } + } +} + +// Single-pass queue stats computation (shared by status bar + stats panel). +// Also tracks inProgressBytes so the status bar doesn't need a second scan. +// +// Memoized within a single tick: back-to-back calls (updateStatusBar + +// updateStatsPanel fire together 4×/sec during upload) share one scan. The +// cache is cleared on microtask so the next tick picks up fresh state. +let _queueStatsCache = null; +function _computeQueueStats() { + if (_queueStatsCache) return _queueStatsCache; + + let remaining = 0, inProgress = 0, done = 0, errors = 0; + let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0; + const total = queueJobs.length; + + for (let i = 0; i < total; i++) { + const job = queueJobs[i]; + const s = job.status; + const bt = job.bytesTotal || 0; + const bu = job.bytesUploaded || 0; + totalSize += bt; + + if (s === 'uploading' || s === 'getting-server' || s === 'retrying') { + inProgress++; + remaining++; + inProgressBytes += bu; + bytesRemaining += Math.max(0, bt - bu); + remainingSize += Math.max(0, bt - bu); + } else if (s === 'preview' || s === 'queued') { + remaining++; + bytesRemaining += Math.max(0, bt - bu); + remainingSize += Math.max(0, bt - bu); + } else if (s === 'done') { + done++; + } else if (s === 'error') { + errors++; + } else if (s !== 'skipped') { + remainingSize += Math.max(0, bt - bu); + } + } + + _queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes }; + queueMicrotask(() => { _queueStatsCache = null; }); + return _queueStatsCache; +} + +function updateStatusBar() { + const stats = _computeQueueStats(); + + const etaSeconds = lastUploadStats.globalSpeedKbs > 0 + ? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024)) + : 0; + + const stateText = lastUploadStats.state === 'uploading' + ? 'Upload läuft...' + : lastUploadStats.state === 'stopping' + ? 'Stoppt nach aktiven Uploads...' + : uploading + ? 'Upload vorbereitet...' + : 'Bereit'; + + document.getElementById('sbState').textContent = stateText; + document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0); + const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes; + const totalSize = Math.max(stats.totalSize, _sessionTotalBytes); + document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`; + document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`; + document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`; + document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`; + document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`; + document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`; + document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`; + document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`; +} + +// --- Health Check --- + +function renderHealthCheckResults(results) { + const container = document.getElementById('healthCheckResults'); + if (!container) return; + if (!results || results.length === 0) { container.innerHTML = ''; return; } + + container.innerHTML = results.map(item => { + const status = item.status || 'skipped'; + return `
+ ${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')} + [${status.toUpperCase()}] + ${escapeHtml(item.message || '')} +
`; + }).join(''); +} + +async function executeHealthCheck(hosters, _mode) { + renderHealthCheckResults([]); + const result = await window.api.runHealthCheck({ hosters }); + const rows = result && Array.isArray(result.results) ? result.results : []; + rows.forEach((row) => { + if (!row) return; + const key = row.accountId || row.hoster; + if (key) { + accountStatuses[key] = { + status: row.status || 'unchecked', + message: row.message || '' + }; + } + }); + renderHealthCheckResults(rows); + renderAccounts(); + renderHosterModal(); + return rows; +} + +async function runHealthCheck(mode = 'manual', requestedHosters = null) { + if (healthCheckRunning || (uploading && mode === 'manual')) return []; + // Build check list: all enabled accounts with creds + let hosters; + if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { + hosters = requestedHosters; + } else { + hosters = getAccountsWithCredsFlat() + .filter(({ account }) => account.enabled !== false) + .map(({ name, account }) => ({ hoster: name, accountId: account.id })); + } + if (hosters.length === 0) { + if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); + return []; + } + healthCheckRunning = true; + // Mark all accounts as checking + for (const h of hosters) { + const key = typeof h === 'string' ? h : (h.accountId || h.hoster); + accountStatuses[key] = { status: 'checking', message: '' }; + } + renderAccounts(); + try { + return await executeHealthCheck(hosters, mode); + } catch (err) { + renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); + return []; + } finally { + healthCheckRunning = false; + renderAccounts(); + } +} + +// --- Settings --- +function renderSettings() { + const container = document.getElementById('settingsHosters'); + container.innerHTML = ''; + + const globalSettings = config.globalSettings || {}; + const configuredAccounts = getAvailableHosters(); + const generalPanel = document.createElement('div'); + generalPanel.className = 'hoster-settings-panel'; + generalPanel.innerHTML = ` +
+ + Allgemein + System +
+
+ +
+ + + 0 = nur pro Hoster +
+
+ + + 0 = unbegrenzt +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + + +
+
+ + +
+
+ `; + container.appendChild(generalPanel); + + // Toggle general panel + generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { + const body = generalPanel.querySelector('.hoster-panel-body'); + const arrow = generalPanel.querySelector('.panel-arrow'); + const isOpen = body.style.display !== 'none'; + body.style.display = isOpen ? 'none' : 'block'; + arrow.innerHTML = isOpen ? '▶' : '▼'; + }); + + // --- Folder Monitor Panel --- + const fm = globalSettings.folderMonitor || {}; + const folderMonitorPanel = document.createElement('div'); + folderMonitorPanel.className = 'hoster-settings-panel'; + folderMonitorPanel.innerHTML = ` +
+ + Ordnerüberwachung + ${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'} +
+ + `; + container.appendChild(folderMonitorPanel); + + // Toggle folder monitor panel + folderMonitorPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { + const body = folderMonitorPanel.querySelector('.hoster-panel-body'); + const arrow = folderMonitorPanel.querySelector('.panel-arrow'); + const isOpen = body.style.display !== 'none'; + body.style.display = isOpen ? 'none' : 'block'; + arrow.innerHTML = isOpen ? '▶' : '▼'; + }); + + // Update badge immediately on checkbox/path change + const updateFmBadge = () => { + const b = document.getElementById('folderMonitorStatusBadge'); + if (!b) return; + const enabled = document.getElementById('fmEnabledInput')?.checked; + const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim(); + if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; } + else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; } + }; + document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge); + document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge); + + document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => { + const folder = await window.api.folderMonitorSelectFolder(); + if (folder) { + document.getElementById('fmFolderPathInput').value = folder; + updateFmBadge(); + scheduleSettingsSave(); + } + }); + + // --- Remote Control Panel --- + const remoteSettings = globalSettings.remote || {}; + const remotePanel = document.createElement('div'); + remotePanel.className = 'hoster-settings-panel'; + remotePanel.innerHTML = ` +
+ + Fernsteuerung + ${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'} +
+ + `; + container.appendChild(remotePanel); + + // Toggle remote panel + remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => { + const body = remotePanel.querySelector('.hoster-panel-body'); + const arrow = remotePanel.querySelector('.panel-arrow'); + const isOpen = body.style.display !== 'none'; + body.style.display = isOpen ? 'none' : 'block'; + arrow.innerHTML = isOpen ? '▶' : '▼'; + }); + + // Copy token + document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => { + const token = document.getElementById('remoteTokenInput').value; + if (token) { + await window.api.copyToClipboard(token); + document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!'; + setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500); + } + }); + + // Regenerate token + document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => { + const newToken = await window.api.remoteGenerateToken(); + document.getElementById('remoteTokenInput').value = newToken; + scheduleSettingsSave(); + }); + + // Update status + window.api.remoteStatus().then(status => { + const el = document.getElementById('remoteConnectionStatus'); + if (!el) return; + if (status.running) { + el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; + el.style.color = '#10b981'; + } else { + el.textContent = 'Nicht aktiv'; + el.style.color = '#94a3b8'; + } + }).catch(() => {}); + + // Live client count updates (listener registered once in init, not here) + + // --- Backup Panel --- + const backupPanel = document.createElement('div'); + backupPanel.className = 'hoster-settings-panel'; + backupPanel.innerHTML = ` +
+ + Backup + System +
+ + `; + container.appendChild(backupPanel); + + backupPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { + const body = backupPanel.querySelector('.hoster-panel-body'); + const arrow = backupPanel.querySelector('.panel-arrow'); + const isOpen = body.style.display !== 'none'; + body.style.display = isOpen ? 'none' : 'block'; + arrow.innerHTML = isOpen ? '▶' : '▼'; + }); + + document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport()); + document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport()); + + // --- Separator before hoster panels --- + const separator = document.createElement('div'); + separator.style.cssText = 'height:16px'; + container.appendChild(separator); + + if (configuredAccounts.length === 0) { + const empty = document.createElement('div'); + empty.className = 'settings-empty'; + empty.innerHTML = '

Noch keine Account-Einstellungen vorhanden.

Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.'; + container.appendChild(empty); + } + + for (const { name } of configuredAccounts) { + const hs = hosterSettings[name] || {}; + const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0'; + + const panel = document.createElement('div'); + panel.className = 'hoster-settings-panel'; + + panel.innerHTML = ` +
+ + ${escapeHtml(getHosterLabel(name))} + Aktiv +
+ + `; + + container.appendChild(panel); + + // Toggle panel + panel.querySelector('.hoster-panel-header').addEventListener('click', () => { + const body = panel.querySelector('.hoster-panel-body'); + const arrow = panel.querySelector('.panel-arrow'); + const isOpen = body.style.display !== 'none'; + body.style.display = isOpen ? 'none' : 'block'; + arrow.innerHTML = isOpen ? '▶' : '▼'; + }); + } + + document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); + document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); + document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => { + const btn = e.target; + btn.disabled = true; + btn.textContent = 'Prüfe...'; + try { + const result = await window.api.checkForUpdate(); + if (result && result.available) { + showUpdateBanner(result); + btn.textContent = 'Update gefunden!'; + } else { + btn.textContent = 'Kein Update verfügbar'; + } + } catch { + btn.textContent = 'Fehler beim Prüfen'; + } + setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000); + }); + container.querySelectorAll('.settings-autosave').forEach((input) => { + const eventName = input.type === 'checkbox' ? 'change' : 'input'; + input.addEventListener(eventName, scheduleSettingsSave); + }); +} + +async function chooseLogFilePath() { + const folders = await window.api.selectFolder(); + if (!folders || !folders[0]) return; + const normalized = folders[0].replace(/[\\\/]+$/, ''); + document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; + scheduleSettingsSave(); +} + +function scheduleSettingsSave() { + const feedback = document.getElementById('saveFeedback'); + if (feedback) feedback.textContent = 'Speichert...'; + clearTimeout(settingsSaveTimer); + settingsSaveTimer = setTimeout(() => { + saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => { + if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`; + }); + }, 350); +} + +async function saveSettings(options = {}) { + const { feedbackText = 'Gespeichert!' } = options; + const newHosterSettings = { ...(config.hosterSettings || {}) }; + const globalSettings = { + ...(config.globalSettings || {}), + logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), + sessionLog: !!document.getElementById('sessionLogInput')?.checked, + resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked, + parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)), + scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked, + removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked, + showDropTarget: !!document.getElementById('showDropTargetInput')?.checked, + globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)), + folderMonitor: { + enabled: !!document.getElementById('fmEnabledInput')?.checked, + folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(), + recursive: !!document.getElementById('fmRecursiveInput')?.checked, + filterMode: document.getElementById('fmFilterModeInput')?.value || 'include', + extensions: (document.getElementById('fmExtensionsInput')?.value || '').trim(), + skipDuplicates: !!document.getElementById('fmSkipDuplicatesInput')?.checked, + delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3), + autoStart: !!document.getElementById('fmAutoStartInput')?.checked, + hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster) + }, + remote: { + enabled: !!document.getElementById('remoteEnabledInput')?.checked, + port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)), + token: (document.getElementById('remoteTokenInput')?.value || '').trim(), + allowInput: !!document.getElementById('remoteAllowInputInput')?.checked + } + }; + + // Always on top setting + const aotCheckbox = document.getElementById('alwaysOnTopInput'); + if (aotCheckbox) { + const newAot = !!aotCheckbox.checked; + if (newAot !== alwaysOnTopState) { + alwaysOnTopState = newAot; + await window.api.setAlwaysOnTop(alwaysOnTopState); + } + } + + // Drop target window + const dtCheckbox = document.getElementById('showDropTargetInput'); + if (dtCheckbox) { + if (dtCheckbox.checked) await window.api.showDropTarget(); + else await window.api.hideDropTarget(); + } + + for (const name of HOSTERS) { + const hs = { ...(hosterSettings[name] || {}) }; + document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { + const field = input.dataset.hs; + if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024)); + else hs[field] = parseInt(input.value, 10) || 0; + }); + newHosterSettings[name] = hs; + } + + // Fire both saves in parallel instead of serializing the two IPC round-trips. + // Skip the getConfig refetch — we just wrote it, we know the new state, and + // the round-trip added 100–200ms of UI stall per keystroke (autosave fires + // on every input change). + await Promise.all([ + window.api.saveHosterSettings(newHosterSettings), + window.api.saveGlobalSettings(globalSettings) + ]); + config.hosterSettings = newHosterSettings; + config.globalSettings = globalSettings; + hosterSettings = newHosterSettings; + clearTimeout(settingsSaveTimer); + + // Start/stop folder monitor based on settings + const fmSettings = globalSettings.folderMonitor; + const badge = document.getElementById('folderMonitorStatusBadge'); + if (fmSettings && fmSettings.enabled && fmSettings.folderPath) { + try { + await window.api.folderMonitorStart(fmSettings); + if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; } + } catch { + if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; } + } + } else { + await window.api.folderMonitorStop(); + if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; } + } + + // Start/stop remote server based on settings + const remoteSettings = globalSettings.remote; + const remoteBadge = document.getElementById('remoteStatusBadge'); + if (remoteSettings) { + try { + await window.api.remoteSaveSettings(remoteSettings); + if (remoteBadge) { + remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'; + remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`; + } + // Update status display + const status = await window.api.remoteStatus(); + const statusEl = document.getElementById('remoteConnectionStatus'); + if (statusEl) { + if (status.running) { + statusEl.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`; + statusEl.style.color = '#10b981'; + } else { + statusEl.textContent = 'Nicht aktiv'; + statusEl.style.color = '#94a3b8'; + } + } + } catch {} + } + + const feedback = document.getElementById('saveFeedback'); + feedback.textContent = feedbackText; + setTimeout(() => { + if (feedback.textContent === feedbackText) { + feedback.textContent = 'Änderungen werden automatisch gespeichert.'; + } + }, 1800); +} + +// --- Accounts --- +function getCredentialLabel(name, account) { + if (!account) return 'Keine Zugangsdaten'; + if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`; + if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`; + // Fallback + if (account.username && account.password) return `Login: ${account.username}`; + if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`; + return 'Keine Zugangsdaten'; +} + +const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' }; + +function _buildAccountCardHtml(name, account, idx) { + const isDisabled = account.enabled === false; + const st = accountStatuses[account.id] || { status: 'unchecked', message: '' }; + const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft'); + const statusClass = isDisabled ? 'disabled' : st.status; + const credLabel = getCredentialLabel(name, account); + const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; + const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; + + return ` + `; +} + +// Replace only the one card for `accountId` instead of re-rendering the whole +// container. Runs on enable/disable, single health check, priority-badge bumps +// after a reorder — anywhere we only change one card's state. +function updateAccountCard(accountId) { + const container = document.getElementById('accountsList'); + if (!container) return; + const found = findAccountById(accountId); + if (!found) return; + const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`); + if (!card) return; + const accounts = config.hosters[found.name] || []; + const idx = accounts.findIndex(a => a.id === accountId); + if (idx < 0) return; + const tmp = document.createElement('div'); + tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx); + card.replaceWith(tmp.firstElementChild); +} + +let _accountListenersBound = false; + +function renderAccounts() { + const container = document.getElementById('accountsList'); + if (!container) return; + ensureAccountStatusEntries(); + + const allAccounts = getAllAccountsFlat(); + const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn'); + if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning; + + if (allAccounts.length === 0) { + container.innerHTML = ` +
+

Keine Accounts vorhanden

+ Klicke auf "Account hinzufügen", um einen Hoster einzurichten. +
`; + if (!_accountListenersBound) bindAccountListeners(container); + return; + } + + const byHoster = {}; + for (const { name, account } of allAccounts) { + if (!byHoster[name]) byHoster[name] = []; + byHoster[name].push(account); + } + + let html = ''; + for (const name of HOSTERS) { + const accounts = byHoster[name]; + if (!accounts || accounts.length === 0) continue; + html += `'; + } + container.innerHTML = html; + + if (!_accountListenersBound) bindAccountListeners(container); +} + +// Single set of delegated listeners on the accounts container. Bound once on +// the first render and reused for every subsequent in-place update / card +// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners +// per render — with 20 accounts that's 180 listener create/destroy cycles on +// every enable/disable click. +function bindAccountListeners(container) { + _accountListenersBound = true; + container.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); + if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); + if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); + if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck); + }); + + let draggedCard = null; + container.addEventListener('dragstart', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (!card) return; + draggedCard = card; + card.classList.add('dragging'); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + }); + container.addEventListener('dragend', () => { + if (draggedCard) draggedCard.classList.remove('dragging'); + draggedCard = null; + container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below')); + }); + container.addEventListener('dragover', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (!card || !draggedCard || draggedCard === card) return; + if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const rect = card.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + card.classList.toggle('drag-over-above', e.clientY < midY); + card.classList.toggle('drag-over-below', e.clientY >= midY); + }); + container.addEventListener('dragleave', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (card) card.classList.remove('drag-over-above', 'drag-over-below'); + }); + container.addEventListener('drop', (e) => { + const card = e.target.closest('.account-card[draggable]'); + if (!card || !draggedCard || draggedCard === card) return; + e.preventDefault(); + card.classList.remove('drag-over-above', 'drag-over-below'); + const hosterName = card.dataset.accountHoster; + if (draggedCard.dataset.accountHoster !== hosterName) return; + + const draggedId = draggedCard.dataset.accountId; + const targetId = card.dataset.accountId; + const accounts = config.hosters[hosterName]; + if (!Array.isArray(accounts)) return; + + const fromIdx = accounts.findIndex(a => a.id === draggedId); + if (fromIdx < 0) return; + const [moved] = accounts.splice(fromIdx, 1); + const rect = card.getBoundingClientRect(); + const insertBefore = e.clientY < rect.top + rect.height / 2; + const newToIdx = accounts.findIndex(a => a.id === targetId); + accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved); + + // Move the DOM node in place — no full re-render. + if (insertBefore) card.before(draggedCard); else card.after(draggedCard); + + // The Primär / Fallback #N badges just changed for the whole group. + for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id); + + // Persist in the background. saveConfig is idempotent; we don't need to + // await here or re-fetch — our in-memory config is already the truth. + window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); + }); +} + +async function toggleAccount(accountId) { + const found = findAccountById(accountId); + if (!found) return; + found.account.enabled = !found.account.enabled; + syncSelectedUploadHosters(); + // In-place: swap only the one affected card. No full re-render, no IPC + // refetch, no flicker. Rapid click-toggles now feel instant even with 50 + // accounts in the list. + updateAccountCard(accountId); + renderHosterSummary(); + window.api.saveConfig({ hosters: config.hosters }).catch(() => {}); +} + +async function checkSingleAccount(accountId) { + if (!accountId || healthCheckRunning) return; + const found = findAccountById(accountId); + if (!found) return; + healthCheckRunning = true; + accountStatuses[accountId] = { status: 'checking', message: '' }; + updateAccountCard(accountId); + try { + const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); + const rows = result && Array.isArray(result.results) ? result.results : []; + const row = rows.find(r => r.accountId === accountId); + if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' }; + } catch (err) { + accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; + } finally { + healthCheckRunning = false; + } + updateAccountCard(accountId); +} + +function getCredsFieldsHtml(authType, account) { + account = account || {}; + if (authType === 'login') { + return ` +
+ + +
+
+ + + +
`; + } + // API key + return ` +
+ + + +
`; +} + +function openAccountModal(editAccountId) { + editingAccountId = editAccountId || null; + const modal = document.getElementById('accountModal'); + const title = document.getElementById('accountModalTitle'); + const subtitle = document.getElementById('accountModalSubtitle'); + const hosterRow = document.getElementById('accountHosterRow'); + const hosterSelect = document.getElementById('accountHosterSelect'); + const credsContainer = document.getElementById('accountCredsFields'); + const statusEl = document.getElementById('accountModalStatus'); + const saveBtn = document.getElementById('saveAccountBtn'); + + statusEl.textContent = ''; + statusEl.className = 'account-modal-status'; + + if (editingAccountId) { + // Edit mode + const found = findAccountById(editingAccountId); + if (!found) return; + title.textContent = 'Account bearbeiten'; + subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`; + hosterRow.style.display = 'none'; + saveBtn.textContent = 'Speichern & prüfen'; + credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account); + } else { + // Add mode — always show all options (multiple accounts per hoster allowed) + title.textContent = 'Account hinzufügen'; + subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; + hosterRow.style.display = 'flex'; + saveBtn.textContent = 'Anlegen & prüfen'; + hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => + `` + ).join(''); + const firstOpt = HOSTER_ADD_OPTIONS[0]; + credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}); + } + + // Toggle visibility buttons + credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { + btn.addEventListener('click', () => { + const input = btn.previousElementSibling; + input.type = input.type === 'password' ? 'text' : 'password'; + }); + }); + + modal.style.display = 'flex'; +} + +function closeAccountModal() { + document.getElementById('accountModal').style.display = 'none'; + _hideOtpField(); + editingAccountId = null; +} + +function openDeleteAccountModal(accountId) { + const found = findAccountById(accountId); + if (!found) return; + const modal = document.getElementById('deleteAccountModal'); + const msg = document.getElementById('deleteAccountMessage'); + msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`; + modal.dataset.accountId = accountId; + modal.style.display = 'flex'; +} + +function closeDeleteModal() { + document.getElementById('deleteAccountModal').style.display = 'none'; +} + +async function deleteAccount(accountId) { + const found = findAccountById(accountId); + if (!found) return; + // Remove account from the array + const accounts = config.hosters[found.name]; + if (Array.isArray(accounts)) { + config.hosters[found.name] = accounts.filter(a => a.id !== accountId); + } + delete accountStatuses[accountId]; + await window.api.saveConfig({ hosters: config.hosters }); + config = await window.api.getConfig(); + ensureAccountStatusEntries(); + syncSelectedUploadHosters(); + if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); + renderAccounts(); + renderHosterSummary(); + renderHosterModal(); + renderSettings(); + closeDeleteModal(); +} + +function readAccountCredsFromModal(authType) { + if (authType === 'login') { + const username = (document.getElementById('accField_username')?.value || '').trim(); + const password = (document.getElementById('accField_password')?.value || '').trim(); + return { enabled: !!(username && password), authType: 'login', username, password }; + } + // API + const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); + return { enabled: !!apiKey, authType: 'api', apiKey }; +} + +async function saveAccount() { + let hosterName, authType, accountId; + + if (editingAccountId) { + // Edit existing account + const found = findAccountById(editingAccountId); + if (!found) return; + hosterName = found.name; + authType = found.account.authType || 'login'; + accountId = editingAccountId; + } else { + // Add new account + const selectValue = document.getElementById('accountHosterSelect')?.value; + if (!selectValue) return; + const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue); + if (!opt) return; + hosterName = opt.hoster; + authType = opt.authType; + accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + } + + const creds = readAccountCredsFromModal(authType); + if (!creds.enabled) { + const statusEl = document.getElementById('accountModalStatus'); + statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; + statusEl.className = 'account-modal-status error'; + return; + } + + // Save credentials + if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = []; + if (editingAccountId) { + // Update existing account in array + const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId); + if (idx >= 0) { + config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds }; + } + } else { + // Add new account + config.hosters[hosterName].push({ id: accountId, ...creds }); + } + await window.api.saveConfig({ hosters: config.hosters }); + config = await window.api.getConfig(); + + // Show checking status + const statusEl = document.getElementById('accountModalStatus'); + const saveBtn = document.getElementById('saveAccountBtn'); + statusEl.textContent = 'Prüfe Login...'; + statusEl.className = 'account-modal-status checking'; + saveBtn.disabled = true; + + accountStatuses[accountId] = { status: 'checking', message: '' }; + syncSelectedUploadHosters(); + renderAccounts(); + renderHosterSummary(); + renderHosterModal(); + renderSettings(); + + // Check if OTP was entered (for retry after OTP prompt) + const otpInput = document.getElementById('accField_otp'); + const otp = otpInput ? otpInput.value.trim() : ''; + + // Run health check for this specific account (include OTP if provided) + const checkPayload = { hoster: hosterName, accountId }; + if (otp) checkPayload.otp = otp; + + try { + const result = await window.api.runHealthCheck({ hosters: [checkPayload] }); + const rows = result && Array.isArray(result.results) ? result.results : []; + const row = rows.find(r => r.accountId === accountId); + if (row && row.status === 'otp_required') { + // Show OTP input field if not already visible + accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' }; + statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; + statusEl.className = 'account-modal-status error'; + _showOtpField(); + saveBtn.textContent = 'OTP bestätigen'; + } else if (row && (row.status === 'ok' || row.status === 'warn')) { + accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' }; + statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; + statusEl.className = 'account-modal-status ok'; + _hideOtpField(); + setTimeout(() => closeAccountModal(), 1200); + } else { + const msg = (row && row.message) || 'Login fehlgeschlagen'; + accountStatuses[accountId] = { status: 'error', message: msg }; + statusEl.textContent = msg; + statusEl.className = 'account-modal-status error'; + } + } catch (err) { + accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; + statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; + statusEl.className = 'account-modal-status error'; + } finally { + saveBtn.disabled = false; + ensureAccountStatusEntries(); + renderAccounts(); + renderHosterSummary(); + renderHosterModal(); + renderSettings(); + } +} + +function _showOtpField() { + if (document.getElementById('accField_otp')) return; // already visible + const container = document.getElementById('accountCredsFields'); + const otpHtml = ` +
+ + +
`; + container.insertAdjacentHTML('beforeend', otpHtml); + // Auto-focus the OTP field + setTimeout(() => document.getElementById('accField_otp')?.focus(), 50); +} + +function _hideOtpField() { + const row = document.getElementById('otpFieldRow'); + if (row) row.remove(); +} + +// --- History --- +async function loadHistory() { + const history = await window.api.getHistory(); + const container = document.getElementById('historyContainer'); + + if (!history || history.length === 0) { + historyRowsData = []; + container.innerHTML = '

Noch keine Uploads.

'; + return; + } + + historySortState = { key: 'date', direction: 'desc' }; + historyRowsData = []; + let order = 0; + + for (const batch of history) { + const dt = formatDateTime(batch.timestamp || new Date()); + for (const file of (batch.files || [])) { + for (const result of (file.results || [])) { + if (result.status === 'aborted' || result.status === 'error') continue; + historyRowsData.push({ + date: dt.text, dateTs: dt.ts, + filename: file.name || '', host: result.hoster || '', + link: result.download_url || result.embed_url || '', + isError: false, order: order++ + }); + } + } + } + + renderHistoryTable(container); +} + +async function exportHistory() { + const history = await window.api.getHistory(); + if (!history || history.length === 0) { + alert('Kein Verlauf zum Exportieren vorhanden.'); + return; + } + + const asCsv = confirm('Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON'); + const format = asCsv ? 'csv' : 'json'; + const result = await window.api.exportHistory(format); + + if (!result || result.canceled) return; + if (!result.ok) { + alert(result.error || 'Export fehlgeschlagen.'); + return; + } + + showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`); +} + +// Memoize sort result: invalidated only when data length changes or sort state changes. +// Selection changes and re-renders reuse the cached sorted array — a big win when +// the panel has thousands of rows and the sort is stable. +let _recentSortCache = { sig: '', result: [] }; + +function sortRecentFiles(data) { + const { key, direction } = recentSortState; + const sig = `${key}|${direction}|${data.length}`; + if (_recentSortCache.sig === sig) return _recentSortCache.result; + + const sorted = data.slice(); + const dir = direction === 'asc' ? 1 : -1; + sorted.sort((a, b) => { + if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order)); + if (key === 'filename') return dir * _collatorDE.compare(a.filename, b.filename); + if (key === 'host') return dir * _collatorDE.compare(a.host, b.host); + if (key === 'link') return dir * _collatorDE.compare(a.link, b.link); + return 0; + }); + _recentSortCache = { sig, result: sorted }; + return sorted; +} + +function updateRecentSortHeaders() { + const head = document.getElementById('recentFilesHead'); + if (!head) return; + head.querySelectorAll('th[data-recent-sort]').forEach(th => { + const key = th.dataset.recentSort; + const active = recentSortState.key === key; + const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕'; + th.classList.toggle('active', active); + const indicator = th.querySelector('.sort-indicator'); + if (indicator) indicator.textContent = arrow; + }); +} + +let _recentListenersBound = false; + +function _buildRecentRowHtml(row) { + const cls = `recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}`; + return `` + + `${escapeHtml(row.date)}` + + `${escapeHtml(row.filename)}` + + `${escapeHtml(row.host)}` + + `${escapeHtml(row.link)}` + + ``; +} + +// Tracks the last rendered dataset so we can append-only when the user is just +// accumulating new uploads (the default case: sort=date desc, rows only grow). +let _recentLastRenderedSig = ''; +let _recentLastRenderedLen = 0; + +function renderRecentUploadsPanel() { + const tbody = document.getElementById('recentFilesBody'); + if (!tbody) return; + if (!sessionFilesData.length) { + tbody.innerHTML = 'Noch keine Uploads in dieser Session.'; + _recentLastRenderedSig = ''; + _recentLastRenderedLen = 0; + return; + } + + const rows = sortRecentFiles(sessionFilesData); + const sig = `${recentSortState.key}|${recentSortState.direction}`; + const dateDescAppendOnly = sig === 'date|desc' + && _recentLastRenderedSig === sig + && rows.length > _recentLastRenderedLen + && tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen; + + let wasAppendOnly = false; + if (dateDescAppendOnly) { + // Fast path: only new rows (date desc puts newest on top) — insert them + // at the top without rebuilding the 5000-row tbody below. + const added = rows.length - _recentLastRenderedLen; + let html = ''; + for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]); + tbody.insertAdjacentHTML('afterbegin', html); + wasAppendOnly = true; + } else { + tbody.innerHTML = rows.map(_buildRecentRowHtml).join(''); + } + _recentLastRenderedSig = sig; + _recentLastRenderedLen = rows.length; + + // Event delegation – bind once, not per-row + if (!_recentListenersBound) { + _recentListenersBound = true; + tbody.addEventListener('click', (e) => { + const tr = e.target.closest('.recent-file-row'); + if (!tr) return; + // Clear queue selection when clicking in recent panel — class-toggle only. + if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); } + const id = parseInt(tr.dataset.order, 10); + if (e.ctrlKey || e.metaKey) { + if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); + else selectedRecentIds.add(id); + } else if (e.shiftKey && selectedRecentIds.size > 0) { + // Reuse the already-sorted array from the sort cache instead of + // querying every .recent-file-row in the DOM (O(visible) vs O(N) + // on large panels). + const sortedOrders = (_recentSortCache.result || sortRecentFiles(sessionFilesData)) + .map(r => r.order); + const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o)); + const curIdx = sortedOrders.indexOf(id); + if (lastIdx >= 0 && curIdx >= 0) { + const from = Math.min(lastIdx, curIdx); + const to = Math.max(lastIdx, curIdx); + for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]); + } + } else { + selectedRecentIds.clear(); + selectedRecentIds.add(id); + } + // Selection change — toggle classes, no tbody rebuild. + applyRecentSelectionClasses(); + }); + + tbody.addEventListener('dblclick', (e) => { + const tr = e.target.closest('.recent-file-row'); + if (!tr || tr.classList.contains('error')) return; + const link = tr.dataset.link; + if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } + }); + } + + // Sort headers only change when the sort state changes — skip on appends. + if (!wasAppendOnly) updateRecentSortHeaders(); +} + +function renderHistoryTable(container) { + if (!container || !historyRowsData.length) { + if (container) container.innerHTML = '

Noch keine Uploads.

'; + return; + } + + const rows = sortHistoryRows(historyRowsData); + const headerCell = (key, label) => { + const active = historySortState.key === key; + const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; + return `${label}${dir}`; + }; + + let html = ` + ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} + `; + + rows.forEach(row => { + html += ` + + + + + `; + }); + + html += '
${escapeHtml(row.date)}${escapeHtml(row.filename)}${escapeHtml(row.host)}
'; + container.innerHTML = html; + + container.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.historySort; + if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; + else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; } + renderHistoryTable(container); + }); + }); + + container.querySelectorAll('.history-row').forEach(row => { + row.addEventListener('click', () => { + if (row.classList.contains('error')) return; + const link = row.dataset.link; + if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } + }); + }); +} + +function sortHistoryRows(rows) { + const { key, direction } = historySortState; + const factor = direction === 'asc' ? 1 : -1; + return rows.slice().sort((a, b) => { + const cmp = key === 'date' ? a.dateTs - b.dateTs : _collatorDE.compare(String(a[key] || ''), String(b[key] || '')); + return (cmp || a.order - b.order) * factor; + }); +} + +// Flush pending queue state on window close (sync IPC — blocks until save completes) +window.addEventListener('beforeunload', () => { + // Flush pending settings save if user changed settings right before closing + if (settingsSaveTimer) { + clearTimeout(settingsSaveTimer); + settingsSaveTimer = null; + try { saveSettings(); } catch {} + } + clearTimeout(queuePersistTimer); + queuePersistTimer = null; + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: buildPersistedQueueState() + }; + config.globalSettings = globalSettings; + window.api.saveGlobalSettingsSync(globalSettings); }); -renderQueuePreview(); + +// --- Setup Listeners --- +function setupListeners() { + document.getElementById('addFilesBtn').addEventListener('click', pickFiles); + document.getElementById('addFolderBtn').addEventListener('click', pickFolder); + document.getElementById('startUploadBtn').addEventListener('click', startUpload); + document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload); + + // Recent files sort headers + document.getElementById('recentFilesHead').addEventListener('click', (e) => { + const th = e.target.closest('th[data-recent-sort]'); + if (!th) return; + const key = th.dataset.recentSort; + if (recentSortState.key === key) { + recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; + } else { + recentSortState.key = key; + recentSortState.direction = key === 'date' ? 'desc' : 'asc'; + } + renderRecentUploadsPanel(); + }); + + // Recent files context menu + document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => { + const tr = e.target.closest('.recent-file-row'); + if (!tr) return; + e.preventDefault(); + e.stopPropagation(); + const id = parseInt(tr.dataset.order, 10); + if (!selectedRecentIds.has(id)) { + selectedRecentIds.clear(); + selectedRecentIds.add(id); + renderRecentUploadsPanel(); + } + const menu = document.getElementById('recentContextMenu'); + menu.style.display = 'block'; + menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px'; + }); + + document.getElementById('recentContextMenu').addEventListener('click', (e) => { + const item = e.target.closest('.ctx-item'); + if (!item) return; + hideContextMenu(); + const action = item.dataset.action; + if (action === 'recent-copy-links') copySelectedRecentLinks(); + else if (action === 'recent-delete') deleteSelectedRecentFiles(); + }); + document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs); + document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs); + document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress); + document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads); + document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top')); + document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up')); + document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down')); + document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom')); + document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual')); + document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); + document.getElementById('clearRecentFilesBtn').addEventListener('click', clearAllRecentFiles); + document.getElementById('exportRecentFilesBtn').addEventListener('click', exportAllRecentFiles); + document.getElementById('retryFailedBtn').addEventListener('click', () => { + queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); }); + retrySelectedJobs(); + }); + document.getElementById('importLogBtn').addEventListener('click', importUploadLog); + document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection); + document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal); + document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal); + document.getElementById('selectAllHostersBtn').addEventListener('click', () => { + document.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.checked = true; + input.closest('.hoster-option')?.classList.add('selected'); + }); + }); + document.getElementById('clearHostersBtn').addEventListener('click', () => { + document.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.checked = false; + input.closest('.hoster-option')?.classList.remove('selected'); + }); + }); + document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); + + document.getElementById('clearHistoryBtn').addEventListener('click', async () => { + if (!confirm('Verlauf wirklich löschen?')) return; + await window.api.clearHistory(); + loadHistory(); + }); + document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); + + // Auto health check toggle + const autoToggle = document.getElementById('autoHealthCheckToggle'); + if (autoToggle) { + autoToggle.checked = autoHealthCheckEnabled; + autoToggle.addEventListener('change', (e) => { + autoHealthCheckEnabled = !!e.target.checked; + try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {} + }); + } + + // Virtual scroll for large queues + const queueContainer = document.getElementById('queueContainer'); + if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true }); + + // Queue table sorting + document.querySelectorAll('#queueTable th.sortable').forEach(th => { + th.addEventListener('click', (e) => { + // Don't sort if click was on the resizer handle + if (e.target.classList.contains('col-resizer')) return; + const key = th.dataset.sort; + if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc'; + else { queueSortState.key = key; queueSortState.direction = 'asc'; } + _lastVisibleRange = { start: -1, end: -1 }; // force full rebuild after re-sort + renderQueueTable(); + }); + }); + + // Queue table column resizing (JDownloader-style) + setupColumnResizing(); + + // Shutdown cancel + document.getElementById('cancelShutdownBtn').addEventListener('click', async () => { + await window.api.cancelShutdown(); + if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; } + document.getElementById('shutdownOverlay').style.display = 'none'; + }); + + // Click on empty area in queue → deselect all + document.getElementById('upload-view').addEventListener('click', (e) => { + if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { + if (selectedJobIds.size > 0) { + selectedJobIds.clear(); + renderQueueTable(); + updateQueueActionButtons(); + } + } + }); + + // Right-click on upload view background + document.getElementById('upload-view').addEventListener('contextmenu', (e) => { + if (e.target.closest('.queue-row')) return; // handled per row + if (queueJobs.length === 0 && selectedFiles.length === 0) return; // nothing in queue + e.preventDefault(); + showContextMenu(e.clientX, e.clientY); + }); + + document.getElementById('hosterModal').addEventListener('click', (e) => { + if (e.target.id === 'hosterModal') cancelHosterModal(); + }); + + // Account management + document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null)); + document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal); + document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal); + document.getElementById('saveAccountBtn').addEventListener('click', saveAccount); + document.getElementById('accountModal').addEventListener('click', (e) => { + if (e.target.id === 'accountModal') closeAccountModal(); + }); + + // Account hoster select change → update credential fields + document.getElementById('accountHosterSelect').addEventListener('change', (e) => { + const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value); + const authType = opt ? opt.authType : 'login'; + const credsContainer = document.getElementById('accountCredsFields'); + credsContainer.innerHTML = getCredsFieldsHtml(authType, {}); + credsContainer.querySelectorAll('.toggle-vis').forEach(btn => { + btn.addEventListener('click', () => { + const input = btn.previousElementSibling; + input.type = input.type === 'password' ? 'text' : 'password'; + }); + }); + document.getElementById('accountModalStatus').textContent = ''; + document.getElementById('accountModalStatus').className = 'account-modal-status'; + }); + + // Delete account modal + document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal); + document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal); + document.getElementById('confirmDeleteBtn').addEventListener('click', () => { + const modal = document.getElementById('deleteAccountModal'); + const accountId = modal.dataset.accountId; + if (accountId) deleteAccount(accountId); + }); + document.getElementById('deleteAccountModal').addEventListener('click', (e) => { + if (e.target.id === 'deleteAccountModal') closeDeleteModal(); + }); +} + +// --- Update UI --- +function showUpdateBanner(info) { + const banner = document.getElementById('updateBanner'); + const msg = document.getElementById('updateMessage'); + if (!banner || !msg) return; + msg.textContent = `Update v${info.remoteVersion} verfügbar`; + banner.style.display = 'flex'; + document.getElementById('installUpdateBtn').onclick = async () => { + msg.textContent = 'Update wird heruntergeladen...'; + document.getElementById('installUpdateBtn').disabled = true; + await persistQueueStateNow().catch(() => {}); // Save queue before update restart + await window.api.installUpdate(); + }; + document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; }; +} + +function handleUpdateProgress(data) { + const msg = document.getElementById('updateMessage'); + if (!msg) return; + if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`; + else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...'; + else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...'; + else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...'; + else if (data.stage === 'error') { + msg.textContent = `Update fehlgeschlagen: ${data.error}`; + const btn = document.getElementById('installUpdateBtn'); + if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; } + } +} + +// --- Shutdown --- +let shutdownCountdownInterval = null; +function handleShutdownCountdown(data) { + const overlay = document.getElementById('shutdownOverlay'); + const msgEl = document.getElementById('shutdownMessage'); + const secEl = document.getElementById('shutdownSeconds'); + overlay.style.display = 'flex'; + + const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' }; + let remaining = data.seconds || 60; + secEl.textContent = remaining; + msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; + + if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval); + shutdownCountdownInterval = setInterval(() => { + remaining--; + secEl.textContent = remaining; + msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`; + if (remaining <= 0) { clearInterval(shutdownCountdownInterval); } + }, 1000); +} + +// --- Auto-deduplicate restored queue against own upload log on startup --- +async function _autoDeduplicateFromLog() { + if (queueJobs.length === 0) return; + try { + const entries = await window.api.readOwnUploadLog(); + if (!entries || entries.length === 0) return; + const logKeys = new Set(); + for (const entry of entries) { + logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`); + } + let removed = 0; + queueJobs = queueJobs.filter(job => { + const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`; + if (logKeys.has(key)) { + if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`); + removed++; + return false; + } + return true; + }); + if (removed > 0) { + rebuildJobIndex(); + syncSelectedFilesFromQueue(); + window.api.debugLog(`auto-dedup: removed ${removed} already-uploaded jobs from restored queue (${entries.length} log entries)`); + } + } catch {} +} + +// --- Log import: remove already-uploaded file+hoster combos from queue --- +async function importUploadLog() { + const result = await window.api.importUploadLog(); + if (!result || result.canceled) return; + const entries = result.entries || []; + if (entries.length === 0) { + showCopyToast('Keine Einträge im Log gefunden'); + return; + } + + // Build lookup Set: "filename_lower|hoster" + const logKeys = new Set(); + for (const entry of entries) { + logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`); + } + + // Find queue jobs that match (already uploaded) + let removed = 0; + queueJobs = queueJobs.filter(job => { + const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`; + if (logKeys.has(key) && job.status !== 'done') { + removeJobFromIndex(job); + // Mark as completed so buildQueuePreview won't re-create them + if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`); + removed++; + return false; + } + return true; + }); + + if (removed > 0) { + selectedJobIds.clear(); + syncSelectedFilesFromQueue(); + rebuildJobIndex(); + renderQueueTable(); + updateUploadView(); + updateStatusBar(); + persistQueueStateSoon(true); + } + + showCopyToast(`${removed} bereits hochgeladene Jobs aus Queue entfernt (${entries.length} Log-Einträge gelesen)`); +} + +// --- Link operations --- +function copyAllLinks() { + const links = queueJobs + .filter(j => j.status === 'done' && j.result) + .map(j => j.result.download_url || j.result.embed_url || '') + .filter(Boolean); + if (links.length > 0) { + window.api.copyToClipboard(links.join('\n')); + showCopyToast(`${links.length} Links kopiert`); + } +} + +// --- Utilities --- +function formatSize(bytes) { + if (!bytes || bytes <= 0) return '0 B'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; +} + +function formatSpeed(kbs) { + if (!kbs || kbs <= 0) return '0 kB/s'; + if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s'; + return Math.round(kbs) + ' kB/s'; +} + +function formatTime(seconds) { + if (!seconds || seconds <= 0) return '00:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`; + return `${pad(m)}:${pad(s)}`; +} + +function pad(n) { return String(Math.floor(n)).padStart(2, '0'); } + +function formatDateTime(value) { + const date = value instanceof Date ? value : new Date(value); + const safeDate = Number.isNaN(date.getTime()) ? new Date() : date; + return { + ts: safeDate.getTime(), + text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + + ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + }; +} + +function loadAutoCheckPreference() { + try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; } + catch { return true; } +} + +// --- Queue table column resizing (JDownloader-style) --- +function restoreQueueColumnWidths() { + try { + const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY); + if (!raw) return; + const widths = JSON.parse(raw); + if (!widths || typeof widths !== 'object') return; + for (const [col, px] of Object.entries(widths)) { + const th = document.querySelector(`#queueTable th[data-col="${col}"]`); + if (th && typeof px === 'number' && px > 20) { + th.style.width = px + 'px'; + } + } + } catch {} +} + +function saveQueueColumnWidths() { + try { + const widths = {}; + document.querySelectorAll('#queueTable th[data-col]').forEach(th => { + widths[th.dataset.col] = th.getBoundingClientRect().width; + }); + localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(widths)); + } catch {} +} + +function setupColumnResizing() { + const headers = document.querySelectorAll('#queueTable th[data-col]'); + headers.forEach(th => { + const resizer = th.querySelector('.col-resizer'); + if (!resizer) return; + + resizer.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX; + const startWidth = th.getBoundingClientRect().width; + resizer.classList.add('dragging'); + document.body.classList.add('col-resizing'); + + const onMove = (ev) => { + const delta = ev.clientX - startX; + const newWidth = Math.max(40, startWidth + delta); + th.style.width = newWidth + 'px'; + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + resizer.classList.remove('dragging'); + document.body.classList.remove('col-resizing'); + saveQueueColumnWidths(); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); +} + +// Single-pass escape instead of 4 chained .replace(/x/g, ...) calls. +// Hot path on large table rebuilds — every text cell runs through one of these. +const _HTML_ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' }; +const _HTML_ESC_RE = /[&<>"]/g; +const _ATTR_ESC_MAP = { '&': '&', '"': '"', "'": ''' }; +const _ATTR_ESC_RE = /[&"']/g; + +function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(_HTML_ESC_RE, (c) => _HTML_ESC_MAP[c]); +} + +function escapeAttr(str) { + if (!str) return ''; + return String(str).replace(_ATTR_ESC_RE, (c) => _ATTR_ESC_MAP[c]); +} + +function showCopyToast(msg, durationMs) { + const toast = document.getElementById('copyToast'); + toast.textContent = msg; + toast.classList.add('show'); + clearTimeout(toast._timer); + toast._timer = setTimeout(() => toast.classList.remove('show'), durationMs || 1500); +} + +// --- Resize handle for recent-files panel --- +{ + const resizer = document.getElementById('recentFilesResizer'); + const panel = document.getElementById('recentFilesPanel'); + if (resizer && panel) { + let startY = 0; + let startH = 0; + + resizer.addEventListener('mousedown', (e) => { + e.preventDefault(); + startY = e.clientY; + startH = panel.getBoundingClientRect().height; + resizer.classList.add('dragging'); + document.body.style.cursor = 'ns-resize'; + document.body.style.userSelect = 'none'; + + const onMove = (e2) => { + const delta = startY - e2.clientY; + const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta)); + panel.style.flex = `0 0 ${newH}px`; + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + resizer.classList.remove('dragging'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + } +} + +// --- Recent panel tabs --- +document.querySelectorAll('.recent-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active')); + tab.classList.add('active'); + const panel = document.getElementById(tab.dataset.panel); + if (panel) panel.classList.add('active'); + const hint = document.getElementById('recentFilesHint'); + if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'; + }); +}); + +// --- Stats panel update --- +let statsStartTime = 0; +let statsRunTimer = null; + +function formatBytes(bytes) { + if (bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i]; +} + +function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function updateStatsPanel() { + const stats = _computeQueueStats(); + const remaining = stats.total - stats.done - stats.errors; + + const el = (id) => document.getElementById(id); + if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total; + if (el('statQueueDone')) el('statQueueDone').textContent = stats.done; + if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining; + if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress; + if (el('statQueueError')) el('statQueueError').textContent = stats.errors; + if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize); + if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize); + + const speed = lastUploadStats.globalSpeedKbs || 0; + if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s'; + if (el('statEta')) { + if (speed > 0 && stats.remainingSize > 0) { + el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024))); + } else { + el('statEta').textContent = '--:--'; + } + } + if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0); +} + +// --- Start --- +init(); diff --git a/src/index.html b/src/index.html index f6f2dae..37e3bcd 100644 --- a/src/index.html +++ b/src/index.html @@ -1,122 +1,319 @@ - + - - Multi-Hoster-Upload 2.0 - - + + + Multi-Hoster-Upload + -
-
Multi-Hoster-Upload v2.0
- -
+ -
-
+ + +
+
+
+ +
+
+ + +
+
+ +
-
Dateien hierher ziehen oder Button klicken
- +
📁
+

Dateien hierher ziehen oder klicken

-
- -
-
- -
-
- - - + -
-
-
- -
-
-
- -
-
-

Globale Einstellungen

-
- - +
+ + + + + + + + + + + + + + +
FilenameUploaded / SizeHostStatusZeitRestSpeedProgress
-
- - + + - -
-
-
-
- - -
-

-    
-
- - - -
+
+
+
+
+

Accounts

+

Hoster-Zugangsdaten verwalten und prüfen

+
+
+ + + +
+
+ +
+
+
- + + + + +
+
+

Upload-Einstellungen

+

Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.

+
+
+ Änderungen werden automatisch gespeichert. + +
+
+
+ +
+
+
+

Upload-Verlauf

+
+ + +
+
+
+
+
+ + + + + +
+ Bereit + | + 0 kB/s + | + 0 B + | + ETA --:-- + | + Aktive Verbindungen 0 + | + Gesamt 0 + | + Remaining 0 + | + In Progress 0 + | + Done 0 + | + Error 0 +
+ +
+ + + + + + + diff --git a/src/styles.css b/src/styles.css index e127e8c..bcf37d5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,334 +1,1007 @@ -* { box-sizing: border-box; } - :root { - --bg: #16181c; - --bg-alt: #1c1f24; - --bg-hover: #24282e; - --border: #2d3139; - --text: #e6e8eb; - --text-dim: #9aa3ae; - --accent: #4aa3ff; - --accent-hover: #6ab5ff; - --danger: #ff5a5f; - --success: #3ece78; - --warn: #ffbf4a; + --bg-primary: #16181c; + --bg-secondary: #20242b; + --bg-card: #262b33; + --bg-card-hover: #2e3440; + --bg-input: #1b2027; + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.18); + --text: #edf1f7; + --text-muted: #9ea7b3; + --text-dim: #727b88; + --accent: #3ea7ff; + --accent-end: #65d8ff; + --success: #43c788; + --success-end: #89e0b0; + --danger: #f06f5a; + --warning: #f0c36c; + --link-color: #7edcff; + --panel-shadow: 0 14px 30px rgba(0, 0, 0, 0.22); } -html, body { - margin: 0; - padding: 0; - height: 100%; - background: var(--bg); - color: var(--text); - font: 13px/1.5 "Segoe UI", system-ui, sans-serif; - overflow: hidden; -} +* { margin: 0; padding: 0; box-sizing: border-box; } body { + font-family: 'Aptos', 'Segoe UI Variable Text', 'Bahnschrift', sans-serif; + background: + radial-gradient(circle at top right, rgba(62, 167, 255, 0.08), transparent 28%), + linear-gradient(180deg, #14171b 0%, #191d23 100%); + height: 100vh; + overflow: hidden; + color: var(--text); + user-select: none; display: flex; flex-direction: column; } -.app-header { +/* Tab Bar */ +.tab-bar { display: flex; - align-items: center; - gap: 24px; - padding: 8px 16px; - background: var(--bg-alt); + gap: 2px; + padding: 8px 16px 0; border-bottom: 1px solid var(--border); -} - -.app-title { - font-weight: 600; - font-size: 14px; -} - -.app-title .ver { - color: var(--accent); - font-weight: normal; - margin-left: 4px; -} - -.tabs { - display: flex; - gap: 4px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.08)); + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 100; } .tab { + padding: 10px 18px 9px; background: transparent; - color: var(--text-dim); border: none; - padding: 8px 14px; + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; cursor: pointer; - border-radius: 4px; - font-size: 13px; + border-bottom: 2px solid transparent; + transition: all 0.2s; } +.tab:hover { color: var(--text); } +.tab.active { color: var(--text); border-bottom-color: var(--accent); background: rgba(255, 255, 255, 0.03); } -.tab:hover { background: var(--bg-hover); color: var(--text); } -.tab.active { background: var(--accent); color: #fff; } - -main { - flex: 1; - overflow: hidden; - position: relative; -} - -.view { - display: none; - height: 100%; - overflow: auto; - padding: 16px; -} - -.view.active { display: block; } - -.drop-zone { - border: 2px dashed var(--border); - border-radius: 6px; - padding: 24px; - text-align: center; - margin-bottom: 16px; - transition: border-color 0.15s; -} - -.drop-zone.hover { border-color: var(--accent); background: rgba(74, 163, 255, 0.05); } - -.drop-hint { +.version-label { + margin-left: auto; + font-size: 0.7rem; color: var(--text-dim); - margin-bottom: 12px; + padding: 4px 8px; + align-self: center; } -.hoster-picker { +/* Update Banner */ +.update-banner { display: flex; align-items: center; gap: 12px; - margin-bottom: 16px; + padding: 6px 16px; + background: linear-gradient(135deg, #667eea22, #764ba222); + border-bottom: 1px solid rgba(102, 126, 234, 0.3); + font-size: 0.8rem; + color: #e0e0e0; + flex-shrink: 0; } +.update-banner span { flex: 1; } -.hoster-picker label { font-weight: 500; } +/* Views */ +.view { display: none; flex: 1; overflow: hidden; flex-direction: column; } +.view.active { display: flex; } -.hoster-checkbox { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border: 1px solid var(--border); - border-radius: 4px; +/* Buttons */ +.btn { + padding: 8px 16px; + border: 1px solid transparent; + border-radius: 8px; cursor: pointer; - margin-right: 6px; - background: var(--bg-alt); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + transition: all 0.2s; } +.btn-xs { padding: 4px 10px; font-size: 11px; border-radius: 4px; } +.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 5px; } +.btn-primary { background: linear-gradient(180deg, var(--accent-end), var(--accent)); color: #092033; box-shadow: inset 0 1px 0 rgba(255,255,255,0.28); } +.btn-primary:hover { filter: brightness(1.05); transform: translateY(-1px); } +.btn-primary:disabled { opacity: 0.5; cursor: default; filter: none; } +.btn-secondary { background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08)); color: var(--text-muted); border: 1px solid var(--border); } +.btn-secondary:hover { border-color: var(--border-hover); color: var(--text); background: rgba(255,255,255,0.05); } +.btn-success { background: linear-gradient(180deg, var(--success-end), var(--success)); color: #082616; box-shadow: inset 0 1px 0 rgba(255,255,255,0.22); } +.btn-success:hover { filter: brightness(1.05); transform: translateY(-1px); } +.btn-success:disabled { opacity: 0.5; cursor: default; filter: none; } +.btn-danger { background: linear-gradient(180deg, #ff8e74, var(--danger)); color: #fff; } +.btn-danger:hover { filter: brightness(1.05); transform: translateY(-1px); } -.hoster-checkbox input { margin: 0; } -.hoster-checkbox.checked { border-color: var(--accent); background: rgba(74, 163, 255, 0.12); } +/* Upload Toolbar */ +.upload-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.08)); + flex-shrink: 0; + flex-wrap: wrap; +} +.toolbar-left { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 200px; } +.toolbar-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.hoster-summary { font-size: 12px; color: var(--text-muted); } +/* Health check */ +.health-check-inline { display: flex; align-items: center; gap: 4px; } +.auto-check-label { display: flex; align-items: center; gap: 3px; font-size: 10px; color: var(--text-muted); cursor: pointer; } +.auto-check-label input { width: 12px; height: 12px; } + +.health-check-results { display: flex; gap: 4px; padding: 0 16px; flex-wrap: wrap; flex-shrink: 0; } +.health-badge { + display: inline-flex; gap: 4px; padding: 2px 8px; + font-size: 10px; border-radius: 3px; align-items: center; +} +.health-badge.ok { background: rgba(0, 184, 148, 0.2); color: var(--success); } +.health-badge.warn { background: rgba(253, 203, 110, 0.2); color: var(--warning); } +.health-badge.error { background: rgba(231, 76, 60, 0.2); color: var(--danger); } +.health-badge.skipped { background: rgba(255,255,255,0.05); color: var(--text-dim); } +.health-tag { font-weight: 600; } + +/* Drop Zone */ +.upload-workspace { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + margin: 16px; + border: 1px dashed rgba(126, 220, 255, 0.28); + border-radius: 18px; + cursor: pointer; + transition: all 0.3s; + color: var(--text-muted); + min-height: 200px; + background: + linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08)), + repeating-linear-gradient(135deg, rgba(255,255,255,0.015), rgba(255,255,255,0.015) 12px, transparent 12px, transparent 24px); + box-shadow: var(--panel-shadow); +} +.drop-zone:hover, .drop-zone.drag-over { border-color: rgba(126, 220, 255, 0.6); background-color: rgba(62, 167, 255, 0.06); } +.drop-icon { font-size: 40px; margin-bottom: 8px; } + +/* Queue Container */ .queue-shell { - background: var(--bg-alt); - border: 1px solid var(--border); - border-radius: 6px; + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; overflow: hidden; } - -.queue-toolbar { +.queue-command-bar { display: flex; - gap: 8px; - padding: 8px 12px; + flex-wrap: wrap; + gap: 2px; align-items: center; + padding: 4px 8px; border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(0,0,0,0.08)); +} +.toolbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 26px; + padding: 3px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--text-muted); + transition: all 0.15s; +} +.toolbar-btn:hover { + background: rgba(255,255,255,0.1); + border-color: rgba(255,255,255,0.15); +} +.toolbar-btn:active { + background: rgba(255,255,255,0.05); + transform: translateY(1px); +} +.toolbar-btn:disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; +} +.toolbar-btn:disabled:hover { + background: transparent; + border-color: transparent; +} +.toolbar-btn svg { + display: block; +} +.toolbar-btn-danger:hover { + background: rgba(229,57,53,0.2); + border-color: rgba(229,57,53,0.3); +} +.toolbar-sep { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} +.queue-container { + flex: 1 1 0; + min-height: 0; + overflow: auto; + padding: 0; + background: rgba(255, 255, 255, 0.02); } -.queue-stats { - margin-left: auto; - font-size: 12px; - color: var(--text-dim); -} - +/* Queue Table */ .queue-table { width: 100%; border-collapse: collapse; - font-size: 12px; + font-size: 11px; + table-layout: fixed; } - -.queue-table th, .queue-table td { - padding: 6px 10px; - border-bottom: 1px solid var(--border); - text-align: left; -} - -.queue-table th { - background: var(--bg); - color: var(--text-dim); - font-weight: 500; +.queue-table thead { position: sticky; top: 0; + z-index: 5; +} +.queue-table th { + padding: 5px 8px; + text-align: left; + background: linear-gradient(180deg, #2a313b, #20252d); + color: var(--text-muted); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + white-space: nowrap; + cursor: default; +} +.queue-table th.sortable { cursor: pointer; } +.queue-table th.sortable:hover { color: var(--text); } +.queue-table th { position: relative; } + +.col-resizer { + position: absolute; + top: 0; + right: 0; + width: 6px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 6; + background: transparent; + transition: background 0.15s; +} +.col-resizer:hover { background: rgba(102, 126, 234, 0.4); } +.col-resizer.dragging { background: rgba(102, 126, 234, 0.6); } +body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-select: none !important; } + +.col-filename { width: 30%; } +.col-size { width: 12%; } +.col-host { width: 12%; } +.col-status { width: 10%; } +.col-elapsed { width: 7%; } +.col-remaining { width: 7%; } +.col-speed { width: 8%; } +.col-progress { width: 14%; } + +.queue-table td { + padding: 4px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.035); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.queue-row.status-done td { color: var(--success); } -.queue-row.status-error td { color: var(--danger); } -.queue-row.status-uploading td { color: var(--accent); } +.virtual-spacer td { padding: 0 !important; border: none !important; } -.progress-bar-bg { - width: 120px; - height: 10px; - background: var(--bg); - border-radius: 5px; - overflow: hidden; +/* Queue Row States */ +.queue-row { transition: background 0.15s; cursor: pointer; } +.queue-row:hover { background: rgba(255, 255, 255, 0.04); } +.queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; } + +.queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); } +.queue-row.status-getting-server { background: rgba(102, 126, 234, 0.05); } +.queue-row.status-retrying { background: rgba(253, 203, 110, 0.08); } +.queue-row.status-done { background: rgba(0, 184, 148, 0.06); } +.queue-row.status-error { background: rgba(231, 76, 60, 0.1); } +.queue-row.status-aborted { background: rgba(240, 195, 108, 0.08); } +.queue-row.status-skipped { background: rgba(255, 255, 255, 0.02); opacity: 0.6; } + +/* Status Badge */ +.status-badge { display: inline-block; - vertical-align: middle; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; +} +.status-badge.status-preview { color: var(--text-muted); } +.status-badge.status-queued { color: var(--text-muted); background: rgba(255,255,255,0.05); } +.status-badge.status-getting-server { color: var(--accent); } +.status-badge.status-uploading { color: #5dabf7; background: rgba(93, 171, 247, 0.15); } +.status-badge.status-retrying { color: var(--warning); background: rgba(253, 203, 110, 0.15); } +.status-badge.status-done { color: var(--success); background: rgba(0, 184, 148, 0.15); } +.status-badge.status-error { color: var(--danger); background: rgba(231, 76, 60, 0.15); } +.status-badge.status-aborted { color: var(--warning); background: rgba(240, 195, 108, 0.16); } +.status-badge.status-skipped { color: var(--text-dim); } + +/* Progress in table cell */ +.progress-cell { display: flex; align-items: center; gap: 4px; } +.progress-bar-bg { + flex: 1; + height: 14px; + background: rgba(255, 255, 255, 0.05); + border-radius: 2px; + overflow: hidden; } .progress-bar-fill { height: 100%; - background: var(--accent); - transition: width 0.2s ease-out; + border-radius: 2px; + will-change: width; } -.progress-bar-fill.status-done { background: var(--success); } +.progress-bar-fill.status-uploading { background: linear-gradient(90deg, #4a90d9, #5dabf7); } +.progress-bar-fill.status-getting-server { background: var(--accent); } +.progress-bar-fill.status-retrying { background: var(--warning); } +.progress-bar-fill.status-done { background: linear-gradient(90deg, var(--success), var(--success-end)); } .progress-bar-fill.status-error { background: var(--danger); } -.progress-pct { margin-left: 6px; font-size: 11px; color: var(--text-dim); } +.progress-bar-fill.status-aborted { background: linear-gradient(90deg, #e0b458, #f0c36c); } +.progress-pct { font-size: 10px; color: var(--text-muted); min-width: 28px; text-align: right; } -.actions-bar { - margin-bottom: 12px; +/* Queue Actions */ +.queue-actions { display: flex; - gap: 8px; + gap: 6px; + padding: 6px 16px; + border-top: 1px solid var(--border); + background: rgba(0, 0, 0, 0.14); + flex-shrink: 0; } -.accounts-list { +.resize-handle { + flex: 0 0 5px; + cursor: ns-resize; + background: var(--border); + position: relative; + z-index: 5; + transition: background 0.15s; +} +.resize-handle:hover, .resize-handle.dragging { + background: var(--accent); +} +.recent-files-panel { + flex: 0 0 180px; display: flex; flex-direction: column; - gap: 8px; + border-top: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255,255,255,0.015), rgba(0,0,0,0.12)); + overflow: auto; } - -.account-card { +.recent-files-header { display: flex; align-items: center; - gap: 12px; - padding: 10px 14px; - background: var(--bg-alt); - border: 1px solid var(--border); - border-radius: 6px; + justify-content: space-between; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); } - -.account-card.disabled { opacity: 0.5; } - -.account-info { +.recent-tabs { + display: flex; + gap: 0; +} +.recent-tab { + padding: 4px 14px; + font-size: 12px; + font-weight: 500; + background: transparent; + border: 1px solid var(--border); + border-bottom: none; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; +} +.recent-tab:first-child { border-radius: 4px 0 0 0; } +.recent-tab:last-child { border-radius: 0 4px 0 0; } +.recent-tab.active { + background: rgba(255,255,255,0.06); + color: var(--text); + border-bottom-color: transparent; +} +.recent-tab:hover:not(.active) { + background: rgba(255,255,255,0.03); + color: var(--text); +} +.recent-tab-body { display: none; flex: 1; min-height: 0; overflow: auto; } +.recent-tab-body.active { display: flex; flex-direction: column; } +.recent-files-hint { + font-size: 11px; + color: var(--text-dim); + margin-left: auto; +} +.recent-files-header #clearRecentFilesBtn { + margin-left: 8px; +} +.stats-grid { + display: flex; + gap: 32px; + padding: 12px 20px; flex: 1; } -.account-info .title { font-weight: 600; } -.account-info .sub { color: var(--text-dim); font-size: 12px; } - -.account-status { - padding: 3px 8px; - border-radius: 3px; +.stats-col { + flex: 1; + min-width: 0; +} +.stats-col h4 { font-size: 11px; - background: rgba(255,255,255,0.06); + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; } -.account-status.ok { background: rgba(62, 206, 120, 0.2); color: var(--success); } -.account-status.error { background: rgba(255, 90, 95, 0.2); color: var(--danger); } - -.settings { - max-width: 600px; -} - -.setting-row { - margin: 10px 0; +.stats-row { display: flex; - flex-direction: column; - gap: 4px; -} - -.setting-row label { - color: var(--text-dim); + justify-content: space-between; font-size: 12px; + padding: 2px 0; + color: var(--text-dim); } - -.setting-row input, .setting-row select { - background: var(--bg); +.stats-row span:last-child { color: var(--text); - border: 1px solid var(--border); - border-radius: 4px; - padding: 6px 10px; - font-size: 13px; + font-variant-numeric: tabular-nums; } - -.setting-row input:focus, .setting-row select:focus { - outline: none; - border-color: var(--accent); +.recent-files-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; } - -.btn { - background: var(--bg-alt); - color: var(--text); - border: 1px solid var(--border); - padding: 7px 14px; - border-radius: 4px; +.recent-files-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + table-layout: fixed; +} +.recent-files-table th { + position: sticky; + top: 0; + z-index: 2; + padding: 6px 8px; + text-align: left; + background: linear-gradient(180deg, #2a313b, #20252d); + color: var(--text-muted); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} +.recent-files-table td { + padding: 5px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.recent-file-row { cursor: pointer; - font-size: 13px; - transition: background 0.1s; + transition: background 0.15s; } - -.btn:hover { background: var(--bg-hover); } -.btn:disabled { opacity: 0.45; cursor: not-allowed; } - -.btn-primary { - background: var(--accent); - color: #fff; - border-color: var(--accent); +.recent-file-row:hover { + background: rgba(255, 255, 255, 0.03); +} +.recent-file-row.selected { background: rgba(102, 126, 234, 0.12) !important; } +.recent-file-row.error { + color: var(--danger); + opacity: 0.75; } -.btn-primary:hover { background: var(--accent-hover); } .modal-overlay { position: fixed; inset: 0; - background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; - z-index: 100; + background: rgba(5, 7, 16, 0.72); + z-index: 2500; + padding: 24px; } - .modal-card { - background: var(--bg-alt); - border: 1px solid var(--border); - border-radius: 8px; - padding: 20px; - width: min(420px, 92vw); - box-shadow: 0 8px 32px rgba(0,0,0,0.5); + width: min(560px, 100%); + max-height: min(80vh, 700px); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(30, 30, 46, 0.98), rgba(20, 20, 32, 0.98)); + border: 1px solid var(--border-hover); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + overflow: hidden; } - -.modal-card h3 { margin-top: 0; } - +.modal-header, .modal-footer { - margin-top: 16px; + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.modal-header { + border-bottom: 1px solid var(--border); +} +.modal-header h3 { + font-size: 18px; + margin-bottom: 4px; +} +.modal-header p, +.modal-hint { + font-size: 12px; + color: var(--text-muted); +} +.modal-body { + padding: 14px 16px 10px; + overflow: auto; +} +.modal-actions-inline { display: flex; gap: 8px; + margin-bottom: 12px; +} +.hoster-modal-list { + display: grid; + gap: 10px; +} +.hoster-option { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); +} +.hoster-option.selected { + border-color: rgba(102, 126, 234, 0.65); + background: rgba(102, 126, 234, 0.12); +} +.hoster-option.disabled { + opacity: 0.45; +} +.hoster-option input { + width: 16px; + height: 16px; +} +.hoster-option-main { + flex: 1; +} +.hoster-option-title { + font-size: 13px; + font-weight: 600; +} +.hoster-option-subtitle { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.icon-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + width: 32px; + height: 32px; + border-radius: 8px; + cursor: pointer; + font-size: 20px; + line-height: 1; +} +.icon-btn:hover { + color: var(--text); + border-color: var(--border-hover); +} + +/* Context Menu */ +.context-menu { + position: fixed; + z-index: 1000; + background: #1e1e30; + border: 1px solid var(--border-hover); + border-radius: 6px; + padding: 4px 0; + min-width: 200px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); +} +.ctx-item { + padding: 6px 16px; + font-size: 12px; + color: var(--text); + cursor: pointer; + transition: background 0.1s; + position: relative; +} +.ctx-item:hover { background: rgba(102, 126, 234, 0.2); } +.ctx-item-danger { color: var(--danger); } +.ctx-item-danger:hover { background: rgba(231, 76, 60, 0.2); } +.ctx-separator { height: 1px; margin: 4px 8px; background: var(--border); } +.ctx-submenu { position: relative; } +.ctx-submenu-items { + display: none; + position: absolute; + left: 100%; + top: -4px; + background: #1e1e30; + border: 1px solid var(--border-hover); + border-radius: 6px; + padding: 4px 0; + min-width: 160px; + max-width: calc(100vw - 16px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); +} +/* Flip submenu to the left when it would overflow the viewport */ +.ctx-submenu-items.flip-left { left: auto; right: 100%; } +.ctx-submenu:hover .ctx-submenu-items { display: block; } + +/* Settings View */ +.settings-container { padding: 16px; overflow: auto; flex: 1; } +.settings-container { background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } +.settings-container h2 { margin-bottom: 4px; font-size: 18px; } +.settings-hint { color: var(--text-muted); font-size: 12px; margin-bottom: 12px; } + +.hoster-settings-panel { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; +} +.hoster-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + cursor: pointer; + transition: background 0.2s; +} +.hoster-panel-header:hover { background: var(--bg-card-hover); } +.panel-arrow { font-size: 10px; color: var(--text-muted); width: 12px; } +.panel-title { font-weight: 600; font-size: 13px; flex: 1; } +.panel-status { font-size: 10px; padding: 2px 8px; border-radius: 3px; } +.panel-status.active { background: rgba(0, 184, 148, 0.2); color: var(--success); } +.panel-status.inactive { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); } + +.hoster-panel-body { padding: 0 14px 14px; } +.settings-divider { height: 1px; background: var(--border); margin: 12px 0; } +.hoster-panel-body h4 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; font-weight: 500; } + +.settings-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.checkbox-row { + margin-bottom: 0; +} +.checkbox-row input[type="checkbox"] { + order: -1; + width: 16px; + height: 16px; +} +.settings-row label { + font-size: 12px; + color: var(--text-muted); + min-width: 130px; + flex-shrink: 0; +} +.key-input, .hs-input { + flex: 1; + padding: 6px 10px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + max-width: 300px; +} +.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; } +.hs-input { max-width: 100px; } +.hint { font-size: 10px; color: var(--text-dim); } +.settings-section-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + margin: 10px 0 6px; + padding-bottom: 3px; + border-bottom: 1px solid var(--border); +} +.settings-section-label:first-child { margin-top: 0; } + +.toggle-vis { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} +.toggle-vis:hover { border-color: var(--border-hover); } + +.settings-save-row { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; } +.settings-backup-section { margin-top: 24px; border-top: 1px solid var(--border); padding-top: 16px; } +.settings-backup-buttons { display: flex; gap: 8px; } +.save-feedback { font-size: 12px; color: var(--success); } +.settings-empty { + padding: 28px 16px; + border: 1px dashed var(--border); + border-radius: 12px; + text-align: center; + color: var(--text-dim); + margin-top: 12px; +} + +.settings-grid-mini { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 16px; +} + +/* Accounts View */ +.accounts-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } +.accounts-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 16px; } +.accounts-header h2 { font-size: 18px; margin-bottom: 2px; } +.accounts-header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; justify-content: flex-end; } - -.log-pre { - background: var(--bg-alt); +.accounts-auto-check { + padding: 6px 10px; border: 1px solid var(--border); - border-radius: 6px; - padding: 12px; - overflow: auto; - font-family: "Cascadia Mono", Consolas, monospace; + border-radius: 8px; + background: rgba(255,255,255,0.03); font-size: 11px; +} +.account-health-results { + padding: 0 0 12px; +} +.accounts-list { display: grid; gap: 8px; } + +.account-card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + transition: background 0.2s; +} +.account-card:hover { background: var(--bg-card-hover); } +.account-card-info { flex: 1; min-width: 0; } +.account-card-title { font-size: 14px; font-weight: 600; } +.account-card-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.account-card-actions { display: flex; gap: 6px; flex-shrink: 0; } + +.account-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + padding: 3px 10px; + border-radius: 4px; + font-weight: 600; + flex-shrink: 0; +} +.account-status.status-ok { background: rgba(0, 184, 148, 0.2); color: var(--success); } +.account-status.status-checking { background: rgba(253, 203, 110, 0.2); color: var(--warning); } +.account-status.status-error { background: rgba(231, 76, 60, 0.2); color: var(--danger); } +.account-status.status-warn { background: rgba(240, 195, 108, 0.2); color: var(--warning); } +.account-status.status-unchecked { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); } +.account-status.status-disabled { background: rgba(255, 255, 255, 0.05); color: var(--text-muted); } +.account-card.account-disabled { opacity: 0.5; } +.account-card.account-disabled:hover { opacity: 0.7; } + +.account-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} +.status-ok .account-status-dot { background: var(--success); } +.status-checking .account-status-dot { background: var(--warning); } +.status-error .account-status-dot { background: var(--danger); } +.status-warn .account-status-dot { background: var(--warning); } +.status-unchecked .account-status-dot { background: var(--text-dim); } + +.account-modal-status { + margin-top: 12px; + font-size: 12px; + min-height: 20px; +} +.account-modal-status.checking { color: var(--warning); } +.account-modal-status.ok { color: var(--success); } +.account-modal-status.error { color: var(--danger); } + +/* Multi-account: drag handle, priority badge, hoster group */ +.account-card-drag-handle { + cursor: grab; + font-size: 14px; color: var(--text-dim); - white-space: pre-wrap; - max-height: calc(100vh - 140px); + padding: 2px 4px; + flex-shrink: 0; + user-select: none; +} +.account-card-drag-handle:hover { color: var(--text-muted); } +.account-card.dragging { opacity: 0.4; } +.account-card.drag-over-above { border-top: 2px solid var(--accent); } +.account-card.drag-over-below { border-bottom: 2px solid var(--accent); } + +.account-priority-badge { + font-size: 10px; + font-weight: 500; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 3px; + margin-left: 6px; } -.toast { +.account-hoster-group { + margin-bottom: 12px; +} +.account-hoster-group-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + padding-left: 4px; +} +.account-hoster-group .account-card { margin-bottom: 4px; } + +.accounts-empty { + text-align: center; + padding: 48px 16px; + color: var(--text-dim); +} +.accounts-empty p { font-size: 14px; margin-bottom: 4px; } +.accounts-empty .hint { font-size: 12px; } + +/* History View */ +.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } +.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.history-header h2 { font-size: 18px; } + +.results-table, .history-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.results-table th, .history-table th { + padding: 6px 8px; + text-align: left; + background: var(--bg-card); + color: var(--text-muted); + font-weight: 600; + font-size: 11px; + border-bottom: 1px solid var(--border); + cursor: pointer; + white-space: nowrap; +} +.results-table th.active, .history-table th.active { color: var(--text); } +.sort-indicator { margin-left: 4px; font-size: 10px; } + +.history-row { + cursor: pointer; + transition: background 0.15s; +} +.history-row:hover { background: rgba(255, 255, 255, 0.03); } +.history-row.selected { background: rgba(102, 126, 234, 0.12); } +.history-row.error { color: var(--danger); opacity: 0.6; } +.history-row td { + padding: 5px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +.empty-state { color: var(--text-dim); text-align: center; padding: 40px; font-size: 14px; } + +/* Statusbar */ +.statusbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 4px 16px; + background: #0a0a14; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} +.sb-separator { color: var(--text-dim); } +.sb-speed { color: var(--link-color); font-weight: 500; } +.sb-total { color: var(--text); } +.sb-eta, +.sb-connections, +.sb-queue-count, +.sb-remaining-count, +.sb-progress-count, +.sb-error-count { color: var(--text-muted); } +.sb-state { flex: 1; } + +/* Copy toast */ +.copy-toast { position: fixed; - bottom: 16px; + bottom: 40px; left: 50%; - transform: translateX(-50%) translateY(40px); - background: var(--bg-alt); - color: var(--text); - border: 1px solid var(--border); - padding: 10px 16px; + transform: translateX(-50%) translateY(20px); + background: rgba(0, 184, 148, 0.9); + color: #fff; + padding: 6px 16px; border-radius: 6px; + font-size: 12px; opacity: 0; pointer-events: none; - transition: opacity 0.2s, transform 0.2s; - z-index: 200; + transition: all 0.3s; + z-index: 2000; } -.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* Shutdown overlay */ +.shutdown-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 3000; +} +.shutdown-box { + background: var(--bg-card); + border: 1px solid var(--border-hover); + border-radius: 12px; + padding: 24px 32px; + text-align: center; +} +.shutdown-box p { margin-bottom: 16px; font-size: 16px; } + +@media (max-width: 900px) { + .recent-files-panel { + max-height: 220px; + } + .modal-overlay { + padding: 12px; + } + .queue-command-bar { + padding-right: 12px; + } + .accounts-header { + flex-direction: column; + } + .accounts-header-actions { + justify-content: flex-start; + } +} + +/* Scrollbars */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); } diff --git a/src/tauri-shim.js b/src/tauri-shim.js new file mode 100644 index 0000000..8cf23c6 --- /dev/null +++ b/src/tauri-shim.js @@ -0,0 +1,208 @@ +// tauri-shim.js — re-creates the Electron-era `window.api` surface on top of +// Tauri 2's core.invoke + event.listen so the v1 renderer keeps working +// unchanged. +// +// Every method here maps to exactly one Rust #[tauri::command] in +// src-tauri/src/commands.rs. Event subscriptions go through window.__TAURI__.event.listen. + +(function () { + const T = window.__TAURI__; + if (!T) { + console.error('Tauri runtime not available'); + window.api = new Proxy({}, { get: () => async () => null }); + return; + } + const invoke = T.core.invoke; + const listen = T.event.listen; + const dialog = T.dialog || {}; + + // Helper: wrap a listen() call to return an unsubscribe fn like v1 did. + function on(name, handler) { + let unlisten = () => {}; + listen(name, (ev) => { try { handler(ev.payload); } catch (e) { console.error(e); } }) + .then(fn => { unlisten = fn; }); + return () => { try { unlisten(); } catch {} }; + } + + const api = { + // --- Config --- + getConfig: () => invoke('get_config'), + saveConfig: (config) => invoke('save_config', { config }), + getHistory: () => invoke('get_history'), + clearHistory: () => invoke('clear_history'), + exportHistory: (format) => invoke('export_history', { format }), + + // --- Hoster settings --- + getHosterSettings: () => invoke('get_hoster_settings'), + saveHosterSettings: (settings) => invoke('save_hoster_settings', { settings }), + + // --- Global settings --- + getGlobalSettings: () => invoke('get_global_settings'), + saveGlobalSettings: (settings) => invoke('save_global_settings', { settings }), + saveGlobalSettingsSync: (settings) => invoke('save_global_settings', { settings }), + + // --- Window state --- + setAlwaysOnTop: (value) => invoke('set_always_on_top', { value }), + getAlwaysOnTop: () => invoke('get_always_on_top'), + setShutdownAfterFinish: (mode) => invoke('set_shutdown_after_finish', { mode }), + getShutdownAfterFinish: () => invoke('get_shutdown_after_finish'), + cancelShutdown: () => invoke('cancel_shutdown'), + + // --- File selection --- + selectFiles: async () => { + if (!dialog || !dialog.open) return null; + const picked = await dialog.open({ multiple: true, directory: false }); + if (!picked) return null; + return Array.isArray(picked) ? picked : [picked]; + }, + selectFolder: async () => { + if (!dialog || !dialog.open) return null; + const picked = await dialog.open({ multiple: false, directory: true }); + if (!picked) return null; + // v1 returned an array of files in the folder; we return the folder and + // let resolveFolderFiles handle enumeration. + const files = await invoke('resolve_folder_files', { folderPath: picked }); + return files || []; + }, + resolveFolderFiles: (folderPath) => invoke('resolve_folder_files', { folderPath }), + + // --- Clipboard --- + copyToClipboard: (text) => invoke('copy_to_clipboard', { text }), + + // --- Path / file utils --- + getPathForFile: (file) => { + // In Tauri we never go through drag-drop file objects; paths come direct. + // This keeps the renderer happy when it tries to resolve a drag event. + return (file && file.path) ? file.path : ''; + }, + + // --- Uploads --- + startUpload: (payload) => invoke('start_upload', { payload }), + addJobsToBatch: (payload) => invoke('add_jobs_to_batch', { payload }), + cancelSelectedJobs: (jobIds) => invoke('cancel_jobs', { jobIds }), + cancelUpload: () => invoke('cancel_batch'), + finishAfterActive: () => invoke('finish_after_active'), + + // --- Health check --- + runHealthCheck: (payload) => invoke('run_health_check', { payload }), + + // --- Backup --- + exportBackup: async () => { + if (!dialog || !dialog.save) return { ok: false, canceled: true }; + const target = await dialog.save({ + defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0,10)}.mhu`, + filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }] + }); + if (!target) return { ok: false, canceled: true }; + try { + await invoke('export_backup', { targetPath: target }); + return { ok: true, path: target }; + } catch (e) { return { ok: false, error: String(e) }; } + }, + importBackup: async (legacyPassword) => { + if (legacyPassword) { + try { + const cfg = await invoke('import_backup_saved', { legacyPassword }); + return { ok: true, config: cfg }; + } catch (e) { + const msg = String(e); + if (msg === 'needs-password' || msg.includes('needs-password')) return { ok: false, needsPassword: true }; + return { ok: false, error: msg }; + } + } + if (!dialog || !dialog.open) return { ok: false, canceled: true }; + const src = await dialog.open({ + multiple: false, directory: false, + filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }] + }); + if (!src) return { ok: false, canceled: true }; + try { + const cfg = await invoke('import_backup', { sourcePath: src }); + return { ok: true, config: cfg }; + } catch (e) { + const msg = String(e); + if (msg === 'needs-password' || msg.includes('needs-password')) return { ok: false, needsPassword: true }; + return { ok: false, error: msg }; + } + }, + + // --- Log / files --- + readOwnUploadLog: () => invoke('read_own_upload_log'), + importUploadLog: () => invoke('import_upload_log'), + saveTextFile: async (defaultName, content, filters) => { + if (!dialog || !dialog.save) return { ok: false, canceled: true }; + const target = await dialog.save({ + defaultPath: defaultName, + filters: filters || [{ name: 'Textdatei', extensions: ['txt', 'csv', 'log'] }] + }); + if (!target) return { ok: false, canceled: true }; + try { + await invoke('save_text_file', { path: target, content }); + return { ok: true, path: target }; + } catch (e) { return { ok: false, error: String(e) }; } + }, + openLogFolder: () => invoke('open_log_folder'), + + // --- Update / version --- + getVersion: () => invoke('get_version'), + checkForUpdate: () => invoke('check_for_update'), + installUpdate: () => invoke('install_update'), + + // --- Folder monitor --- + folderMonitorStart: (settings) => invoke('folder_monitor_start', { settings }), + folderMonitorStop: () => invoke('folder_monitor_stop'), + folderMonitorStatus: () => invoke('folder_monitor_status'), + folderMonitorSelectFolder: async () => { + if (!dialog || !dialog.open) return null; + return dialog.open({ multiple: false, directory: true }); + }, + + // --- Remote control --- + remoteGetSettings: () => invoke('remote_get_settings'), + remoteSaveSettings: (settings) => invoke('remote_save_settings', { settings }), + remoteGenerateToken: () => invoke('remote_generate_token'), + remoteStatus: () => invoke('remote_status'), + + // --- Drop target --- + showDropTarget: () => invoke('show_drop_target'), + hideDropTarget: () => invoke('hide_drop_target'), + + // --- Debug --- + debugLog: (msg) => invoke('debug_log', { msg }), + + // --- Events --- + onUpdateAvailable: (cb) => on('app:update-available', cb), + onUpdateProgress: (cb) => on('app:update-progress', cb), + onUploadProgress: (cb) => on('upload-progress', cb), + onUploadBatchDone: (cb) => on('upload-batch-done', cb), + onUploadStats: (cb) => on('upload-stats', cb), + onShutdownCountdown: (cb) => on('shutdown-countdown', cb), + onAccountSwitched: (cb) => on('account-switched', cb), + onAccountRotationLog: (cb) => on('account-rotation-log', cb), + onUploadLogFallback: (cb) => on('upload-log-fallback', cb), + onLogPathAutoUpdated: (cb) => on('log-path-auto-updated', cb), + onDropTargetFiles: (cb) => on('drop-target-files', cb), + onFolderMonitorNewFiles: (cb) => on('folder-monitor-new-files', cb), + onRemoteClientCount: (cb) => on('remote:client-count', cb), + }; + + // Tauri's file-drop event delivers paths; v1's drop handler expected + // FileList objects. Translate and inject synthetic drop events. + try { + listen('tauri://drag-drop', (ev) => { + const paths = (ev.payload && (ev.payload.paths || ev.payload)) || []; + if (!Array.isArray(paths) || paths.length === 0) return; + const files = paths.map(p => ({ path: p, name: p.split(/[\\/]/).pop(), size: 0, type: '' })); + const dz = document.getElementById('dropZone'); + if (!dz) return; + const ev2 = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }); + try { + Object.defineProperty(ev2.dataTransfer, 'files', { value: files, writable: false }); + } catch {} + // v1 path resolution goes through window.api.getPathForFile — already wired. + dz.dispatchEvent(ev2); + }); + } catch {} + + window.api = api; +})();