Implements basic gui app.

This commit is contained in:
2026-08-23 19:06:34 -07:00
parent 4c5db3f950
commit ae84a31ed6
8 changed files with 1266 additions and 146 deletions

341
Cargo.lock generated
View File

@@ -97,6 +97,27 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -905,6 +926,15 @@ dependencies = [
"syn 3.0.3", "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]] [[package]]
name = "dlopen2" name = "dlopen2"
version = "0.8.2" version = "0.8.2"
@@ -943,6 +973,12 @@ dependencies = [
"tendril", "tendril",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -1020,6 +1056,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 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]] [[package]]
name = "endi" name = "endi"
version = "1.1.1" version = "1.1.1"
@@ -1577,6 +1622,10 @@ dependencies = [
name = "gui" name = "gui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"nextcloud_client",
"open",
"reqwest 0.12.28",
"rfd",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -1584,6 +1633,25 @@ dependencies = [
"tauri-plugin-opener", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1679,6 +1747,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1689,6 +1758,21 @@ dependencies = [
"want", "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]] [[package]]
name = "hyper-tls" name = "hyper-tls"
version = "0.6.0" version = "0.6.0"
@@ -1723,9 +1807,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -2913,6 +2999,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 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]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -3011,6 +3106,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3094,16 +3218,20 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-tls", "hyper-tls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
@@ -3159,6 +3287,42 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rpassword" name = "rpassword"
version = "7.5.4" version = "7.5.4"
@@ -3208,6 +3372,19 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.1" version = "1.15.1"
@@ -3217,6 +3394,17 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -3298,6 +3486,12 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -3685,6 +3879,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.8" version = "1.0.8"
@@ -3748,6 +3948,27 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -4200,6 +4421,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -4224,6 +4446,16 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -4233,6 +4465,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"libc",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@@ -4543,6 +4776,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4745,6 +4984,66 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.104" version = "0.3.104"
@@ -5006,6 +5305,17 @@ dependencies = [
"windows-link 0.1.3", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -5051,6 +5361,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5441,6 +5760,7 @@ dependencies = [
"rustix", "rustix",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid", "uuid",
@@ -5486,6 +5806,26 @@ dependencies = [
"serde", "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]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
@@ -5575,6 +5915,7 @@ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"url",
"winnow 1.0.4", "winnow 1.0.4",
"zcheapstr", "zcheapstr",
"zvariant_derive", "zvariant_derive",

View File

@@ -70,7 +70,6 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type | | 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-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Triage | Feature |
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature | | [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature |
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | 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-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature |
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | 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-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 | | [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 - NUT-008
<a id="nut-012" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-012" class="task" data-status="done" data-task-type="feature"></a>
### Implement GUI (Tauri) Frontend ### Implement GUI (Tauri) Frontend
**ID:** NUT-012 **ID:** NUT-012
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Implement the Tauri-based GUI application with a clean, responsive layout connecting the React frontend to the shared Rust backend. Implement the Tauri-based GUI application with a clean, responsive layout connecting the React frontend to the shared Rust backend.
**Requirements:** **Requirements:**
- [ ] Connect shared Rust backend library to Tauri commands in `gui/src-tauri` - [x] Connect shared Rust backend library to Tauri commands in `gui/src-tauri`
- [ ] Implement responsive application layout with navigation & account indicator - [x] Implement responsive application layout with navigation & account indicator
- [ ] Implement file upload UI with destination path, public share link generation, and password protection - [x] 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] Implement real-time feedback with share links, direct download URLs, and copy actions
**Dependencies:** **Dependencies:**
- NUT-002 - NUT-002

View File

@@ -18,8 +18,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
nextcloud_client = { path = "../../nextcloud_client" }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
rfd = { version = "0.15", default-features = false, features = ["tokio"] }
open = "5"

View File

@@ -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<String>,
pub direct_download_url: Option<String>,
}
#[tauri::command]
pub fn list_accounts() -> Result<Vec<StoredAccount>, String> {
CredentialStore::list_accounts().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_default_account() -> Result<Option<StoredAccount>, 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<LoginFlowInitPayload, String> {
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<String>,
) -> Result<Option<StoredAccount>, 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<String>,
) -> Result<StoredAccount, String> {
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<Vec<String>, 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<String>,
account_id: Option<String>,
) -> Result<GuiUploadResult, String> {
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);
}
}

View File

@@ -1,14 +1,20 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ pub mod commands;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "gui", "productName": "Nextcloud Upload Tool",
"version": "0.1.0", "version": "0.1.0",
"identifier": "me.majinnaibu.nut", "identifier": "me.majinnaibu.nut",
"build": { "build": {
@@ -12,9 +12,10 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "gui", "label": "main",
"width": 800, "title": "Nextcloud Upload Tool",
"height": 600 "width": 900,
"height": 680
} }
], ],
"security": { "security": {

View File

@@ -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 { :root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
font-size: 16px; Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
line-height: 24px; --bg-color: #0f172a;
font-weight: 400; --surface-color: #1e293b;
--surface-card: #334155;
color: #0f0f0f; --border-color: #475569;
background-color: #f6f6f6; --text-main: #f8fafc;
--text-muted: #94a3b8;
font-synthesis: none; --primary: #0284c7;
text-rendering: optimizeLegibility; --primary-hover: #0369a1;
-webkit-font-smoothing: antialiased; --accent: #38bdf8;
-moz-osx-font-smoothing: grayscale; --danger: #ef4444;
-webkit-text-size-adjust: 100%; --danger-hover: #dc2626;
--success: #10b981;
--radius: 8px;
} }
.container { * {
box-sizing: border-box;
margin: 0; 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; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; height: 100vh;
text-align: center; max-width: 900px;
margin: 0 auto;
padding: 16px 20px;
} }
.logo { /* Header */
height: 6em; .app-header {
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex; 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; justify-content: center;
font-size: 16px;
} }
a { .brand-title {
font-weight: 500; font-size: 16px;
color: #646cff; font-weight: 700;
text-decoration: inherit; letter-spacing: -0.2px;
} }
a:hover { .account-selector {
color: #535bf2; 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 { .account-dot {
text-align: center; width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--success);
} }
input, .account-select {
button { background: transparent;
border-radius: 8px; color: var(--text-main);
border: 1px solid transparent; border: none;
padding: 0.6em 1.2em; font-size: 13px;
font-size: 1em; outline: none;
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 {
cursor: pointer; cursor: pointer;
} }
button:hover { /* Navigation Tabs */
border-color: #396cd8; .nav-tabs {
} display: flex;
button:active { gap: 8px;
border-color: #396cd8; margin-top: 14px;
background-color: #e8e8e8; margin-bottom: 14px;
} }
input, .tab-button {
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; outline: none;
} }
#greet-input { /* Toast */
margin-right: 5px; .toast {
} position: fixed;
bottom: 24px;
@media (prefers-color-scheme: dark) { right: 24px;
:root { background: #047857;
color: #f6f6f6; color: white;
background-color: #2f2f2f; padding: 10px 18px;
} border-radius: var(--radius);
font-weight: 600;
a:hover { font-size: 13px;
color: #24c8db; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
} z-index: 1000;
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
} }

View File

@@ -1,50 +1,335 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import "./App.css"; import "./App.css";
function App() { interface StoredAccount {
const [greetMsg, setGreetMsg] = useState(""); id: string;
const [name, setName] = useState(""); label?: string;
username: string;
async function greet() { server_url: string;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ is_default: boolean;
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<StoredAccount[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>("");
const [toastMessage, setToastMessage] = useState<string | null>(null);
// Upload Form State
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [remoteDir, setRemoteDir] = useState<string>("Uploads");
const [createShare, setCreateShare] = useState<boolean>(true);
const [sharePassword, setSharePassword] = useState<string>("");
const [isUploading, setIsUploading] = useState<boolean>(false);
const [uploadResults, setUploadResults] = useState<GuiUploadResult[]>([]);
const showToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => {
setToastMessage(null);
}, 3000);
};
const loadAccounts = async () => {
try {
const list = await invoke<StoredAccount[]>("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<string[]>("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<GuiUploadResult>("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 ( return (
<main className="container"> <div className="app-container">
<h1>Welcome to Tauri + React</h1> {/* Header */}
<header className="app-header">
<div className="row"> <div className="brand-section">
<a href="https://vite.dev" target="_blank"> <div className="brand-logo">N</div>
<img src="/vite.svg" className="logo vite" alt="Vite logo" /> <span className="brand-title">Nextcloud Upload Tool</span>
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div> </div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form <div className="header-actions">
className="row" {accounts.length > 0 ? (
onSubmit={(e) => { <div className="account-selector">
e.preventDefault(); <div className="account-dot" />
greet(); <select
className="account-select"
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
>
{accounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.label ? `${acc.label} (${acc.id})` : acc.id}
</option>
))}
</select>
</div>
) : (
<span style={{ fontSize: 13, color: "var(--danger)" }}>
No Account Connected
</span>
)}
</div>
</header>
{/* Navigation Tabs */}
<nav className="nav-tabs">
<button
className={`tab-button ${activeTab === "upload" ? "active" : ""}`}
onClick={() => setActiveTab("upload")}
>
Upload & Share
</button>
</nav>
{/* Main Content */}
<main className="main-content">
{activeTab === "upload" && (
<div>
{/* File Dropzone / Picker */}
<div className="file-dropzone" onClick={handleSelectFiles}>
<div className="dropzone-title">
📁 Click here to select file(s) for upload
</div>
<div className="dropzone-subtitle">
Upload any document, image, video, or archive to your Nextcloud
</div>
</div>
{/* Selected Files List */}
{selectedFiles.length > 0 && (
<div className="card">
<div className="card-title">
<span>Selected Files ({selectedFiles.length})</span>
<button
className="btn btn-secondary btn-sm"
onClick={() => setSelectedFiles([])}
>
Clear All
</button>
</div>
<div className="selected-files-list">
{selectedFiles.map((file) => (
<div key={file} className="selected-file-chip">
<span>{file}</span>
<button
className="btn btn-sm"
style={{ border: "none", color: "var(--danger)" }}
onClick={(e) => {
e.stopPropagation();
handleRemoveSelectedFile(file);
}} }}
> >
✕
</button>
</div>
))}
</div>
</div>
)}
{/* Upload Options Card */}
<div className="card">
<div className="card-title">Upload Settings</div>
<div className="form-group">
<label className="form-label">Destination Folder on Nextcloud</label>
<input <input
id="greet-input" className="form-input"
onChange={(e) => setName(e.currentTarget.value)} type="text"
placeholder="Enter a name..." value={remoteDir}
onChange={(e) => setRemoteDir(e.target.value)}
placeholder="Uploads or Projects/Folder"
/> />
<button type="submit">Greet</button> </div>
</form>
<p>{greetMsg}</p> <div className="form-group">
<label className="form-checkbox">
<input
type="checkbox"
checked={createShare}
onChange={(e) => setCreateShare(e.target.checked)}
/>
Automatically generate public share link after upload
</label>
</div>
{createShare && (
<div className="form-group">
<label className="form-label">Optional Share Password</label>
<input
className="form-input"
type="password"
value={sharePassword}
onChange={(e) => setSharePassword(e.target.value)}
placeholder="Leave empty for public access without password"
/>
</div>
)}
<button
className="btn btn-primary"
style={{ width: "100%", marginTop: 8 }}
onClick={handleUpload}
disabled={isUploading || selectedFiles.length === 0}
>
{isUploading ? "Uploading..." : `Upload ${selectedFiles.length > 0 ? `(${selectedFiles.length} files)` : ""}`}
</button>
</div>
{/* Upload Results */}
{uploadResults.length > 0 && (
<div className="card">
<div className="card-title">
<span>Upload Results</span>
<button
className="btn btn-secondary btn-sm"
onClick={() => setUploadResults([])}
>
Dismiss
</button>
</div>
{uploadResults.map((r, i) => (
<div key={i} className="result-card">
<div className="result-header">
<span className="result-file-name">{r.file_name}</span>
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
</div>
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 8 }}>
Destination: {r.remote_path}
</div>
{r.share_url && (
<div className="link-row">
<input className="link-input" readOnly value={r.share_url} />
<button
className="btn btn-secondary btn-sm"
onClick={() => handleCopy(r.share_url!, "Share Link")}
>
Copy
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenBrowser(r.share_url!)}
>
Open
</button>
</div>
)}
{r.direct_download_url && (
<div className="link-row">
<input className="link-input" readOnly value={r.direct_download_url} />
<button
className="btn btn-secondary btn-sm"
onClick={() => handleCopy(r.direct_download_url!, "Direct Download Link")}
>
Copy Direct
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</main> </main>
{/* Floating Toast */}
{toastMessage && <div className="toast">{toastMessage}</div>}
</div>
); );
} }