From ae84a31ed65288d45ac839811f0b592e7c3e71b6 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 23 Aug 2026 19:06:34 -0700 Subject: [PATCH] Implements basic gui app. --- Cargo.lock | 341 +++++++++++++++++++++++++++ Tasks.md | 14 +- gui/src-tauri/Cargo.toml | 5 +- gui/src-tauri/src/commands.rs | 244 ++++++++++++++++++++ gui/src-tauri/src/lib.rs | 18 +- gui/src-tauri/tauri.conf.json | 9 +- gui/src/App.css | 418 ++++++++++++++++++++++++++-------- gui/src/App.tsx | 363 +++++++++++++++++++++++++---- 8 files changed, 1266 insertions(+), 146 deletions(-) create mode 100644 gui/src-tauri/src/commands.rs diff --git a/Cargo.lock b/Cargo.lock index a868377..ef1c12a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,27 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -905,6 +926,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -943,6 +973,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1020,6 +1056,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1577,6 +1622,10 @@ dependencies = [ name = "gui" version = "0.1.0" dependencies = [ + "nextcloud_client", + "open", + "reqwest 0.12.28", + "rfd", "serde", "serde_json", "tauri", @@ -1584,6 +1633,25 @@ dependencies = [ "tauri-plugin-opener", ] +[[package]] +name = "h2" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1679,6 +1747,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1689,6 +1758,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1723,9 +1807,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2913,6 +2999,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3011,6 +3106,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3094,16 +3218,20 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -3159,6 +3287,42 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rpassword" version = "7.5.4" @@ -3208,6 +3372,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3217,6 +3394,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3298,6 +3486,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3685,6 +3879,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3748,6 +3948,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4200,6 +4421,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -4224,6 +4446,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4233,6 +4465,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -4543,6 +4776,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4745,6 +4984,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -5006,6 +5305,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5051,6 +5361,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5441,6 +5760,7 @@ dependencies = [ "rustix", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -5486,6 +5806,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5575,6 +5915,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 1.0.4", "zcheapstr", "zvariant_derive", diff --git a/Tasks.md b/Tasks.md index 73f0564..10518ca 100644 --- a/Tasks.md +++ b/Tasks.md @@ -70,7 +70,6 @@ This document defines the complete project roadmap and task tracking system for | ID | Title | Status | Type | |---|---|---|---| -| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature | | [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Triage | Feature | | [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature | | [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature | @@ -93,6 +92,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature | +| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature | | [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Fixed | Feature | --- @@ -320,20 +320,20 @@ Integrate rich progress reporting with `indicatif` (speed, ETA, byte counters), - NUT-008 - + ### Implement GUI (Tauri) Frontend **ID:** NUT-012 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Implement the Tauri-based GUI application with a clean, responsive layout connecting the React frontend to the shared Rust backend. **Requirements:** -- [ ] Connect shared Rust backend library to Tauri commands in `gui/src-tauri` -- [ ] Implement responsive application layout with navigation & account indicator -- [ ] Implement file upload UI with destination path, public share link generation, and password protection -- [ ] Implement real-time feedback with share links, direct download URLs, and copy actions +- [x] Connect shared Rust backend library to Tauri commands in `gui/src-tauri` +- [x] Implement responsive application layout with navigation & account indicator +- [x] Implement file upload UI with destination path, public share link generation, and password protection +- [x] Implement real-time feedback with share links, direct download URLs, and copy actions **Dependencies:** - NUT-002 diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 7104ae3..1f26e3e 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -18,8 +18,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +nextcloud_client = { path = "../../nextcloud_client" } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" - +reqwest = { version = "0.12", features = ["json"] } +rfd = { version = "0.15", default-features = false, features = ["tokio"] } +open = "5" diff --git a/gui/src-tauri/src/commands.rs b/gui/src-tauri/src/commands.rs new file mode 100644 index 0000000..106ea87 --- /dev/null +++ b/gui/src-tauri/src/commands.rs @@ -0,0 +1,244 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use nextcloud_client::{ + initiate_login_flow as api_initiate_login_flow, + poll_login_flow as api_poll_login_flow, + ClientConfig, CredentialStore, NextcloudClient, StoredAccount, UploadOptions, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LoginFlowInitPayload { + pub login_url: String, + pub poll_endpoint: String, + pub poll_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GuiUploadResult { + pub file_path: String, + pub file_name: String, + pub remote_path: String, + pub bytes_uploaded: u64, + pub share_url: Option, + pub direct_download_url: Option, +} + +#[tauri::command] +pub fn list_accounts() -> Result, String> { + CredentialStore::list_accounts().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_default_account() -> Result, String> { + let accounts = CredentialStore::list_accounts().map_err(|e| e.to_string())?; + Ok(accounts.into_iter().find(|a| a.is_default)) +} + +#[tauri::command] +pub fn set_default_account(account_id: String) -> Result<(), String> { + CredentialStore::set_default_account(&account_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_account(account_id: String) -> Result<(), String> { + CredentialStore::delete_account(&account_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn initiate_login_flow(server_url: String) -> Result { + let normalized = ClientConfig::normalize_url(&server_url).map_err(|e| e.to_string())?; + let http = reqwest::Client::new(); + let flow = api_initiate_login_flow(&http, &normalized) + .await + .map_err(|e| e.to_string())?; + + // Attempt to launch desktop browser automatically + let _ = open::that(&flow.login); + + Ok(LoginFlowInitPayload { + login_url: flow.login, + poll_endpoint: flow.poll.endpoint, + poll_token: flow.poll.token, + }) +} + +#[tauri::command] +pub async fn poll_login_flow( + endpoint: String, + token: String, + is_default: bool, + label: Option, +) -> Result, String> { + let http = reqwest::Client::new(); + let poll_res = api_poll_login_flow(&http, &endpoint, &token) + .await + .map_err(|e| e.to_string())?; + + if let Some(creds) = poll_res { + let mut account = CredentialStore::save_account( + &creds.server, + &creds.login_name, + &creds.app_password, + is_default, + ) + .map_err(|e| e.to_string())?; + + if let Some(lbl) = label { + account.label = Some(lbl); + let mut accounts = CredentialStore::list_accounts().map_err(|e| e.to_string())?; + if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) { + a.label = account.label.clone(); + } + CredentialStore::save_accounts(&accounts).map_err(|e| e.to_string())?; + } + + Ok(Some(account)) + } else { + Ok(None) + } +} + +#[tauri::command] +pub async fn manual_login( + server_url: String, + username: String, + app_password: String, + is_default: bool, + label: Option, +) -> Result { + let normalized = ClientConfig::normalize_url(&server_url).map_err(|e| e.to_string())?; + let config = ClientConfig::with_credentials(normalized.as_str(), &username, &app_password) + .map_err(|e| e.to_string())?; + let client = NextcloudClient::new(config).map_err(|e| e.to_string())?; + + // Verify connectivity and credentials + client.test_connection().await.map_err(|e| e.to_string())?; + + let mut account = CredentialStore::save_account( + normalized.as_str(), + &username, + &app_password, + is_default, + ) + .map_err(|e| e.to_string())?; + + if let Some(lbl) = label { + account.label = Some(lbl); + let mut accounts = CredentialStore::list_accounts().map_err(|e| e.to_string())?; + if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) { + a.label = account.label.clone(); + } + CredentialStore::save_accounts(&accounts).map_err(|e| e.to_string())?; + } + + Ok(account) +} + +#[tauri::command] +pub async fn select_files() -> Result, String> { + let files = rfd::AsyncFileDialog::new() + .set_title("Select Files to Upload to Nextcloud") + .pick_files() + .await; + + match files { + Some(handles) => { + let paths = handles + .into_iter() + .map(|h| h.path().to_string_lossy().to_string()) + .collect(); + Ok(paths) + } + None => Ok(Vec::new()), + } +} + +#[tauri::command] +pub async fn upload_file( + file_path: String, + remote_dir: String, + create_share: bool, + share_password: Option, + account_id: Option, +) -> Result { + let client = match account_id { + Some(id) if !id.is_empty() => { + CredentialStore::create_client_for_account(&id).map_err(|e| e.to_string())? + } + _ => CredentialStore::create_client_for_default().map_err(|e| e.to_string())?, + }; + + let local_path = PathBuf::from(&file_path); + if !local_path.exists() { + return Err(format!("File '{}' does not exist", file_path)); + } + + let file_name = local_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + let clean_dir = remote_dir.trim_matches('/'); + let remote_path = if clean_dir.is_empty() { + file_name.clone() + } else { + format!("{clean_dir}/{file_name}") + }; + + let options = UploadOptions { + remote_path: remote_path.clone(), + create_share, + share_password, + overwrite: true, + }; + + let res = client + .upload_and_share(&local_path, &options, None) + .await + .map_err(|e| e.to_string())?; + + Ok(GuiUploadResult { + file_path, + file_name, + remote_path, + bytes_uploaded: res.bytes_uploaded, + share_url: res.share_url, + direct_download_url: res.direct_download_url, + }) +} + +#[cfg(test)] +mod tests { + use super::{GuiUploadResult, LoginFlowInitPayload}; + + #[test] + fn test_login_flow_payload_serialization() { + let payload = LoginFlowInitPayload { + login_url: "https://cloud.example.com/index.php/login/v2/flow/123".to_string(), + poll_endpoint: "https://cloud.example.com/index.php/login/v2/poll".to_string(), + poll_token: "abc-token".to_string(), + }; + + let json = serde_json::to_string(&payload).unwrap(); + let deserialized: LoginFlowInitPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(payload, deserialized); + } + + #[test] + fn test_gui_upload_result_serialization() { + let res = GuiUploadResult { + file_path: "/tmp/test.pdf".to_string(), + file_name: "test.pdf".to_string(), + remote_path: "Uploads/test.pdf".to_string(), + bytes_uploaded: 1024, + share_url: Some("https://cloud.example.com/s/ABC".to_string()), + direct_download_url: Some("https://cloud.example.com/index.php/s/ABC/download".to_string()), + }; + + let json = serde_json::to_string(&res).unwrap(); + let deserialized: GuiUploadResult = serde_json::from_str(&json).unwrap(); + assert_eq!(res, deserialized); + } +} diff --git a/gui/src-tauri/src/lib.rs b/gui/src-tauri/src/lib.rs index 4a277ef..82c08f9 100644 --- a/gui/src-tauri/src/lib.rs +++ b/gui/src-tauri/src/lib.rs @@ -1,14 +1,20 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +pub mod commands; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![ + commands::list_accounts, + commands::get_default_account, + commands::set_default_account, + commands::delete_account, + commands::initiate_login_flow, + commands::poll_login_flow, + commands::manual_login, + commands::select_files, + commands::upload_file + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index ee42fa9..da4fdd7 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "gui", + "productName": "Nextcloud Upload Tool", "version": "0.1.0", "identifier": "me.majinnaibu.nut", "build": { @@ -12,9 +12,10 @@ "app": { "windows": [ { - "title": "gui", - "width": 800, - "height": 600 + "label": "main", + "title": "Nextcloud Upload Tool", + "width": 900, + "height": 680 } ], "security": { diff --git a/gui/src/App.css b/gui/src/App.css index 85f7a4a..8d80951 100644 --- a/gui/src/App.css +++ b/gui/src/App.css @@ -1,116 +1,356 @@ -.logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); -} - -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafb); -} :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; - - color: #0f0f0f; - background-color: #f6f6f6; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --bg-color: #0f172a; + --surface-color: #1e293b; + --surface-card: #334155; + --border-color: #475569; + --text-main: #f8fafc; + --text-muted: #94a3b8; + --primary: #0284c7; + --primary-hover: #0369a1; + --accent: #38bdf8; + --danger: #ef4444; + --danger-hover: #dc2626; + --success: #10b981; + --radius: 8px; } -.container { +* { + box-sizing: border-box; margin: 0; - padding-top: 10vh; + padding: 0; +} + +body { + background-color: var(--bg-color); + color: var(--text-main); + font-family: var(--font-family); + font-size: 14px; + line-height: 1.5; + user-select: none; + -webkit-font-smoothing: antialiased; +} + +.app-container { display: flex; flex-direction: column; - justify-content: center; - text-align: center; + height: 100vh; + max-width: 900px; + margin: 0 auto; + padding: 16px 20px; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; -} - -.logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); -} - -.row { +/* Header */ +.app-header { display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.brand-section { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-logo { + background: var(--primary); + color: white; + font-weight: 800; + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; justify-content: center; + font-size: 16px; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +.brand-title { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.2px; } -a:hover { - color: #535bf2; +.account-selector { + display: flex; + align-items: center; + gap: 6px; + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 4px 8px; } -h1 { - text-align: center; +.account-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--success); } -input, -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -} - -button { +.account-select { + background: transparent; + color: var(--text-main); + border: none; + font-size: 13px; + outline: none; cursor: pointer; } -button:hover { - border-color: #396cd8; -} -button:active { - border-color: #396cd8; - background-color: #e8e8e8; +/* Navigation Tabs */ +.nav-tabs { + display: flex; + gap: 8px; + margin-top: 14px; + margin-bottom: 14px; } -input, -button { +.tab-button { + background: var(--surface-color); + color: var(--text-muted); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.tab-button:hover { + color: var(--text-main); + background: var(--surface-card); +} + +.tab-button.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +/* Content Area */ +.main-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +/* Cards */ +.card { + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 14px; +} + +.card-title { + font-size: 14px; + font-weight: 700; + margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Dropzone */ +.file-dropzone { + background: var(--surface-color); + border: 2px dashed var(--border-color); + border-radius: var(--radius); + padding: 24px 16px; + text-align: center; + cursor: pointer; + margin-bottom: 14px; + transition: border-color 0.2s, background-color 0.2s; +} + +.file-dropzone:hover { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.05); +} + +.dropzone-title { + font-size: 15px; + font-weight: 600; + color: var(--text-main); +} + +.dropzone-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; +} + +/* File Chips */ +.selected-files-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.selected-file-chip { + background: var(--surface-card); + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + display: flex; + align-items: center; + gap: 6px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Form Controls */ +.form-group { + margin-bottom: 12px; +} + +.form-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 4px; +} + +.form-input { + width: 100%; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 8px 12px; + color: var(--text-main); + font-size: 13px; + outline: none; + transition: border-color 0.15s; +} + +.form-input:focus { + border-color: var(--accent); +} + +.form-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + font-weight: 600; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; + border: none; + transition: background-color 0.15s, opacity 0.15s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--surface-card); + color: var(--text-main); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border-color); +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-hover); +} + +/* Results */ +.result-card { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 12px; + margin-bottom: 10px; +} + +.result-header { + display: flex; + justify-content: space-between; + font-weight: 600; + margin-bottom: 4px; +} + +.result-file-name { + color: var(--accent); +} + +.result-size { + color: var(--text-muted); + font-size: 12px; +} + +.link-row { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.link-input { + flex: 1; + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 4px 8px; + color: var(--text-main); + font-size: 12px; outline: none; } -#greet-input { - margin-right: 5px; -} - -@media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; - } - - a:hover { - color: #24c8db; - } - - input, - button { - color: #ffffff; - background-color: #0f0f0f98; - } - button:active { - background-color: #0f0f0f69; - } +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + background: #047857; + color: white; + padding: 10px 18px; + border-radius: var(--radius); + font-weight: 600; + font-size: 13px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 1000; } diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 8286a76..45883cd 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,50 +1,335 @@ -import { useState } from "react"; -import reactLogo from "./assets/react.svg"; +import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; import "./App.css"; -function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); +interface StoredAccount { + id: string; + label?: string; + username: string; + server_url: string; + is_default: boolean; +} - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); - } +interface GuiUploadResult { + file_path: string; + file_name: string; + remote_path: string; + bytes_uploaded: number; + share_url?: string; + direct_download_url?: string; +} + +export function App() { + const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload"); + const [accounts, setAccounts] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(""); + const [toastMessage, setToastMessage] = useState(null); + + // Upload Form State + const [selectedFiles, setSelectedFiles] = useState([]); + const [remoteDir, setRemoteDir] = useState("Uploads"); + const [createShare, setCreateShare] = useState(true); + const [sharePassword, setSharePassword] = useState(""); + const [isUploading, setIsUploading] = useState(false); + const [uploadResults, setUploadResults] = useState([]); + + const showToast = (msg: string) => { + setToastMessage(msg); + setTimeout(() => { + setToastMessage(null); + }, 3000); + }; + + const loadAccounts = async () => { + try { + const list = await invoke("list_accounts"); + setAccounts(list); + const def = list.find((a) => a.is_default); + if (def) { + setSelectedAccount(def.id); + } else if (list.length > 0) { + setSelectedAccount(list[0].id); + } else { + setSelectedAccount(""); + } + } catch (e) { + console.error("Failed to load accounts:", e); + } + }; + + useEffect(() => { + loadAccounts(); + }, []); + + const handleSelectFiles = async () => { + try { + const files = await invoke("select_files"); + if (files && files.length > 0) { + setSelectedFiles((prev) => Array.from(new Set([...prev, ...files]))); + } + } catch (e) { + showToast(`Error picking files: ${e}`); + } + }; + + const handleRemoveSelectedFile = (path: string) => { + setSelectedFiles((prev) => prev.filter((p) => p !== path)); + }; + + const handleUpload = async () => { + if (selectedFiles.length === 0) { + showToast("Please select at least one file to upload."); + return; + } + + if (accounts.length === 0) { + showToast("No account configured. Please configure an account in your credentials store."); + return; + } + + setIsUploading(true); + const results: GuiUploadResult[] = []; + + for (const filePath of selectedFiles) { + try { + const res = await invoke("upload_file", { + filePath, + remoteDir, + createShare, + sharePassword: sharePassword.trim() ? sharePassword.trim() : null, + accountId: selectedAccount ? selectedAccount : null, + }); + results.push(res); + } catch (e) { + showToast(`Failed to upload ${filePath}: ${e}`); + } + } + + setUploadResults(results); + setSelectedFiles([]); + setIsUploading(false); + showToast(`Uploaded ${results.length} file(s) successfully!`); + }; + + const handleCopy = (text: string, label: string) => { + navigator.clipboard.writeText(text); + showToast(`Copied ${label} to clipboard!`); + }; + + const handleOpenBrowser = (url: string) => { + openUrl(url).catch((err) => { + console.error("Failed to open URL:", err); + }); + }; + + const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }; return ( -
-

Welcome to Tauri + React

+
+ {/* Header */} +
+
+
N
+ Nextcloud Upload Tool +
- -

Click on the Tauri, Vite, and React logos to learn more.

+
+ {accounts.length > 0 ? ( +
+
+ +
+ ) : ( + + No Account Connected + + )} +
+
-
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - -
-

{greetMsg}

-
+ {/* Navigation Tabs */} + + + {/* Main Content */} +
+ {activeTab === "upload" && ( +
+ {/* File Dropzone / Picker */} +
+
+ 📁 Click here to select file(s) for upload +
+
+ Upload any document, image, video, or archive to your Nextcloud +
+
+ + {/* Selected Files List */} + {selectedFiles.length > 0 && ( +
+
+ Selected Files ({selectedFiles.length}) + +
+
+ {selectedFiles.map((file) => ( +
+ {file} + +
+ ))} +
+
+ )} + + {/* Upload Options Card */} +
+
Upload Settings
+
+ + setRemoteDir(e.target.value)} + placeholder="Uploads or Projects/Folder" + /> +
+ +
+ +
+ + {createShare && ( +
+ + setSharePassword(e.target.value)} + placeholder="Leave empty for public access without password" + /> +
+ )} + + +
+ + {/* Upload Results */} + {uploadResults.length > 0 && ( +
+
+ Upload Results + +
+ {uploadResults.map((r, i) => ( +
+
+ {r.file_name} + {formatBytes(r.bytes_uploaded)} +
+
+ Destination: {r.remote_path} +
+ + {r.share_url && ( +
+ + + +
+ )} + + {r.direct_download_url && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+ )} +
+ + {/* Floating Toast */} + {toastMessage &&
{toastMessage}
} + ); }