Implements progress bars in gui app.
This commit is contained in:
30
Tasks.md
30
Tasks.md
@@ -66,12 +66,10 @@ This document defines the complete project roadmap and task tracking system for
|
|||||||
---
|
---
|
||||||
|
|
||||||
<a id="tasks-summary"></a>
|
<a id="tasks-summary"></a>
|
||||||
## Tasks Summary (Rendered from task details)
|
## Tasks Summary (Rendered from task-details)
|
||||||
|
|
||||||
| ID | Title | Status | Type |
|
| ID | Title | Status | Type |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature |
|
|
||||||
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature |
|
|
||||||
| [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Triage | Integration |
|
| [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Triage | Integration |
|
||||||
| [NUT-017](#nut-017) | Implement Multi-Account Switching (GUI + CLI) | Triage | Integration |
|
| [NUT-017](#nut-017) | Implement Multi-Account Switching (GUI + CLI) | Triage | Integration |
|
||||||
| [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Triage | Chore |
|
| [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Triage | Chore |
|
||||||
@@ -93,6 +91,8 @@ This document defines the complete project roadmap and task tracking system for
|
|||||||
| [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-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature |
|
||||||
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Fixed | Feature |
|
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Fixed | Feature |
|
||||||
|
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Fixed | Feature |
|
||||||
|
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -361,40 +361,40 @@ Add drag-and-drop file support and a queue system for multiple uploads.
|
|||||||
- NUT-012
|
- NUT-012
|
||||||
|
|
||||||
|
|
||||||
<a id="nut-014" class="task" data-status="triage" data-task-type="feature"></a>
|
<a id="nut-014" class="task" data-status="done" data-task-type="feature"></a>
|
||||||
### Implement GUI Credential Management UI
|
### Implement GUI Credential Management UI
|
||||||
**ID:** NUT-014
|
**ID:** NUT-014
|
||||||
**Status:** Triage
|
**Status:** Fixed
|
||||||
**Type:** Feature
|
**Type:** Feature
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts.
|
Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- [ ] Account list UI
|
- [x] Account list UI
|
||||||
- [ ] Browser-based login button (Login Flow v2)
|
- [x] Browser-based login button (Login Flow v2)
|
||||||
- [ ] Manual login form (server/user/token)
|
- [x] Manual login form (server/user/token)
|
||||||
- [ ] Logout button
|
- [x] Logout button
|
||||||
- [ ] Switch account dropdown
|
- [x] Switch account dropdown
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
- NUT-007
|
- NUT-007
|
||||||
- NUT-012
|
- NUT-012
|
||||||
|
|
||||||
|
|
||||||
<a id="nut-015" class="task" data-status="triage" data-task-type="feature"></a>
|
<a id="nut-015" class="task" data-status="done" data-task-type="feature"></a>
|
||||||
### Implement GUI Upload Progress Bars
|
### Implement GUI Upload Progress Bars
|
||||||
**ID:** NUT-015
|
**ID:** NUT-015
|
||||||
**Status:** Triage
|
**Status:** Fixed
|
||||||
**Type:** Feature
|
**Type:** Feature
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Add per-file and total progress bars to the GUI.
|
Add per-file and total progress bars to the GUI.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- [ ] Per-file progress
|
- [x] Per-file progress
|
||||||
- [ ] Total progress
|
- [x] Total progress
|
||||||
- [ ] Error display
|
- [x] Error display
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
- NUT-003
|
- NUT-003
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
use nextcloud_client::{
|
use nextcloud_client::{
|
||||||
initiate_login_flow as api_initiate_login_flow,
|
initiate_login_flow as api_initiate_login_flow,
|
||||||
poll_login_flow as api_poll_login_flow,
|
poll_login_flow as api_poll_login_flow,
|
||||||
ClientConfig, CredentialStore, NextcloudClient, StoredAccount, UploadOptions,
|
ClientConfig, CredentialStore, NextcloudClient, ProgressEvent, StoredAccount, UploadOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -32,6 +34,13 @@ pub struct GuiUploadResult {
|
|||||||
pub direct_download_url: Option<String>,
|
pub direct_download_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GuiUploadProgressPayload {
|
||||||
|
pub file_path: String,
|
||||||
|
pub bytes_transferred: u64,
|
||||||
|
pub total_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_accounts() -> Result<Vec<StoredAccount>, String> {
|
pub fn list_accounts() -> Result<Vec<StoredAccount>, String> {
|
||||||
CredentialStore::list_accounts().map_err(|e| e.to_string())
|
CredentialStore::list_accounts().map_err(|e| e.to_string())
|
||||||
@@ -184,6 +193,7 @@ pub fn get_file_info(file_path: String) -> Result<FileInfo, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_file(
|
pub async fn upload_file(
|
||||||
|
app: tauri::AppHandle,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
remote_dir: String,
|
remote_dir: String,
|
||||||
create_share: bool,
|
create_share: bool,
|
||||||
@@ -222,8 +232,23 @@ pub async fn upload_file(
|
|||||||
overwrite: true,
|
overwrite: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let app_handle = app.clone();
|
||||||
|
let fp = file_path.clone();
|
||||||
|
let progress_cb: nextcloud_client::ProgressCallback = Arc::new(move |event| {
|
||||||
|
if let ProgressEvent::Progress { bytes_transferred, total_bytes } = event {
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"upload-progress",
|
||||||
|
GuiUploadProgressPayload {
|
||||||
|
file_path: fp.clone(),
|
||||||
|
bytes_transferred,
|
||||||
|
total_bytes,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let res = client
|
let res = client
|
||||||
.upload_and_share(&local_path, &options, None)
|
.upload_and_share(&local_path, &options, Some(progress_cb))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
@@ -239,7 +264,7 @@ pub async fn upload_file(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{FileInfo, GuiUploadResult, LoginFlowInitPayload};
|
use super::{FileInfo, GuiUploadProgressPayload, GuiUploadResult, LoginFlowInitPayload};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_login_flow_payload_serialization() {
|
fn test_login_flow_payload_serialization() {
|
||||||
@@ -267,6 +292,19 @@ mod tests {
|
|||||||
assert_eq!(file_info, deserialized);
|
assert_eq!(file_info, deserialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_upload_progress_payload_serialization() {
|
||||||
|
let payload = GuiUploadProgressPayload {
|
||||||
|
file_path: "/tmp/sample.iso".to_string(),
|
||||||
|
bytes_transferred: 5242880,
|
||||||
|
total_bytes: Some(10485760),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
|
let deserialized: GuiUploadProgressPayload = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(payload, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gui_upload_result_serialization() {
|
fn test_gui_upload_result_serialization() {
|
||||||
let res = GuiUploadResult {
|
let res = GuiUploadResult {
|
||||||
|
|||||||
324
gui/src/App.css
324
gui/src/App.css
@@ -102,6 +102,47 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Navigation Tabs */
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Content Area */
|
/* Content Area */
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -169,6 +210,71 @@ body {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overall Progress */
|
||||||
|
.overall-progress-container {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-progress-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-progress-stats {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-track {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-animated {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
rgba(255, 255, 255, 0.15) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 50%,
|
||||||
|
rgba(255, 255, 255, 0.15) 50%,
|
||||||
|
rgba(255, 255, 255, 0.15) 75%,
|
||||||
|
transparent 75%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
animation: move-stripes 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes move-stripes {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 24px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Queue List */
|
/* Queue List */
|
||||||
.queue-title-meta {
|
.queue-title-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -204,7 +310,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: 280px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
@@ -216,7 +322,7 @@ body {
|
|||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 8px 12px;
|
padding: 10px 12px;
|
||||||
transition: background-color 0.15s, border-color 0.15s;
|
transition: background-color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +371,74 @@ body {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Item Progress Bars */
|
||||||
|
.item-progress-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-progress-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-progress-fill {
|
||||||
|
background: var(--accent);
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-progress-animated {
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||||||
|
animation: pulse-glow 1.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-progress-text {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-item-error {
|
.queue-item-error {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
margin-top: 2px;
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry-inline {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry-inline:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-item-size {
|
.queue-item-size {
|
||||||
@@ -354,6 +524,154 @@ body {
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Account Cards */
|
||||||
|
.account-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-default {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
border: 1px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: rgba(2, 132, 199, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-card-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Mode Toggle */
|
||||||
|
.auth-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode:hover:not(:disabled) {
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mode.active {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Polling State */
|
||||||
|
.polling-box {
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polling-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.polling-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Form Controls */
|
/* Form Controls */
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|||||||
636
gui/src/App.tsx
636
gui/src/App.tsx
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@@ -12,6 +13,12 @@ interface StoredAccount {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoginFlowInitPayload {
|
||||||
|
login_url: string;
|
||||||
|
poll_endpoint: string;
|
||||||
|
poll_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +30,7 @@ interface QueueItem {
|
|||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
bytesTransferred: number;
|
||||||
status: "pending" | "uploading" | "done" | "error";
|
status: "pending" | "uploading" | "done" | "error";
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
result?: GuiUploadResult;
|
result?: GuiUploadResult;
|
||||||
@@ -37,7 +45,17 @@ interface GuiUploadResult {
|
|||||||
direct_download_url?: string;
|
direct_download_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GuiUploadProgressPayload {
|
||||||
|
file_path: string;
|
||||||
|
bytes_transferred: number;
|
||||||
|
total_bytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
// Navigation
|
||||||
|
const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload");
|
||||||
|
|
||||||
|
// Account State
|
||||||
const [accounts, setAccounts] = useState<StoredAccount[]>([]);
|
const [accounts, setAccounts] = useState<StoredAccount[]>([]);
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
@@ -52,6 +70,22 @@ export function App() {
|
|||||||
const [createShare, setCreateShare] = useState<boolean>(true);
|
const [createShare, setCreateShare] = useState<boolean>(true);
|
||||||
const [sharePassword, setSharePassword] = useState<string>("");
|
const [sharePassword, setSharePassword] = useState<string>("");
|
||||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||||
|
const [currentUploadingIndex, setCurrentUploadingIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
// Add Account State
|
||||||
|
const [authMode, setAuthMode] = useState<"browser" | "manual">("browser");
|
||||||
|
const [serverUrl, setServerUrl] = useState<string>("https://");
|
||||||
|
const [accountLabel, setAccountLabel] = useState<string>("");
|
||||||
|
const [setAsDefault, setSetAsDefault] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Manual Auth Fields
|
||||||
|
const [manualUsername, setManualUsername] = useState<string>("");
|
||||||
|
const [manualPassword, setManualPassword] = useState<string>("");
|
||||||
|
const [isAuthenticating, setIsAuthenticating] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Browser Flow State
|
||||||
|
const [loginFlowPayload, setLoginFlowPayload] = useState<LoginFlowInitPayload | null>(null);
|
||||||
|
const pollingIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const fileQueueRef = useRef(fileQueue);
|
const fileQueueRef = useRef(fileQueue);
|
||||||
fileQueueRef.current = fileQueue;
|
fileQueueRef.current = fileQueue;
|
||||||
@@ -69,9 +103,9 @@ export function App() {
|
|||||||
setAccounts(list);
|
setAccounts(list);
|
||||||
const def = list.find((a) => a.is_default);
|
const def = list.find((a) => a.is_default);
|
||||||
if (def) {
|
if (def) {
|
||||||
setSelectedAccount(def.id);
|
setSelectedAccount((prev) => (list.some((a) => a.id === prev) ? prev : def.id));
|
||||||
} else if (list.length > 0) {
|
} else if (list.length > 0) {
|
||||||
setSelectedAccount(list[0].id);
|
setSelectedAccount((prev) => (list.some((a) => a.id === prev) ? prev : list[0].id));
|
||||||
} else {
|
} else {
|
||||||
setSelectedAccount("");
|
setSelectedAccount("");
|
||||||
}
|
}
|
||||||
@@ -97,6 +131,7 @@ export function App() {
|
|||||||
path: info.path,
|
path: info.path,
|
||||||
name: info.name,
|
name: info.name,
|
||||||
size: info.size,
|
size: info.size,
|
||||||
|
bytesTransferred: 0,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -106,6 +141,7 @@ export function App() {
|
|||||||
path: p,
|
path: p,
|
||||||
name,
|
name,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
bytesTransferred: 0,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -121,10 +157,11 @@ export function App() {
|
|||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
|
||||||
// Listen to Tauri Drag-and-Drop events from OS
|
// Listen to Tauri Drag-and-Drop events from OS
|
||||||
let unlisten: (() => void) | undefined;
|
let unlistenDrag: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
const appWindow = getCurrentWebviewWindow();
|
const appWindow = getCurrentWebviewWindow();
|
||||||
appWindow.onDragDropEvent((event) => {
|
appWindow
|
||||||
|
.onDragDropEvent((event) => {
|
||||||
if (event.payload.type === "enter" || event.payload.type === "over") {
|
if (event.payload.type === "enter" || event.payload.type === "over") {
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
} else if (event.payload.type === "drop") {
|
} else if (event.payload.type === "drop") {
|
||||||
@@ -135,17 +172,45 @@ export function App() {
|
|||||||
} else if (event.payload.type === "leave") {
|
} else if (event.payload.type === "leave") {
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}
|
}
|
||||||
}).then((fn) => {
|
})
|
||||||
unlisten = fn;
|
.then((fn) => {
|
||||||
}).catch((e) => {
|
unlistenDrag = fn;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
console.warn("Tauri drag-drop listener not active in current mode:", e);
|
console.warn("Tauri drag-drop listener not active in current mode:", e);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Tauri getCurrentWebviewWindow not available:", e);
|
console.warn("Tauri getCurrentWebviewWindow not available:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen to upload-progress events from Tauri backend
|
||||||
|
let unlistenProgress: (() => void) | undefined;
|
||||||
|
listen<GuiUploadProgressPayload>("upload-progress", (event) => {
|
||||||
|
const { file_path, bytes_transferred, total_bytes } = event.payload;
|
||||||
|
setFileQueue((prev) =>
|
||||||
|
prev.map((item) => {
|
||||||
|
if (item.path === file_path) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
bytesTransferred: bytes_transferred,
|
||||||
|
size: total_bytes && total_bytes > 0 ? total_bytes : item.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}).then((fn) => {
|
||||||
|
unlistenProgress = fn;
|
||||||
|
}).catch((e) => {
|
||||||
|
console.warn("Progress listener setup warning:", e);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (unlisten) unlisten();
|
if (unlistenDrag) unlistenDrag();
|
||||||
|
if (unlistenProgress) unlistenProgress();
|
||||||
|
if (pollingIntervalRef.current) {
|
||||||
|
clearInterval(pollingIntervalRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -209,24 +274,41 @@ export function App() {
|
|||||||
setFileQueue([]);
|
setFileQueue([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetryItem = (id: string) => {
|
||||||
|
setFileQueue((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === id ? { ...item, status: "pending", errorMessage: undefined, bytesTransferred: 0 } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUploadQueue = async () => {
|
const handleUploadQueue = async () => {
|
||||||
const pendingItems = fileQueue.filter((item) => item.status === "pending" || item.status === "error");
|
const pendingItems = fileQueue.filter(
|
||||||
|
(item) => item.status === "pending" || item.status === "error"
|
||||||
|
);
|
||||||
if (pendingItems.length === 0) {
|
if (pendingItems.length === 0) {
|
||||||
showToast("No pending files in the queue to upload.");
|
showToast("No pending files in the queue to upload.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
showToast("No account configured. Please configure an account in your credentials store.");
|
showToast("No account configured. Please add an account in the Accounts tab.");
|
||||||
|
setActiveTab("accounts");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
for (const item of pendingItems) {
|
for (let i = 0; i < pendingItems.length; i++) {
|
||||||
// Mark as uploading
|
const item = pendingItems[i];
|
||||||
|
setCurrentUploadingIndex(i + 1);
|
||||||
|
|
||||||
setFileQueue((prev) =>
|
setFileQueue((prev) =>
|
||||||
prev.map((q) => (q.id === item.id ? { ...q, status: "uploading", errorMessage: undefined } : q))
|
prev.map((q) =>
|
||||||
|
q.id === item.id
|
||||||
|
? { ...q, status: "uploading", bytesTransferred: 0, errorMessage: undefined }
|
||||||
|
: q
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -239,7 +321,17 @@ export function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setFileQueue((prev) =>
|
setFileQueue((prev) =>
|
||||||
prev.map((q) => (q.id === item.id ? { ...q, status: "done", result: res } : q))
|
prev.map((q) =>
|
||||||
|
q.id === item.id
|
||||||
|
? {
|
||||||
|
...q,
|
||||||
|
status: "done",
|
||||||
|
bytesTransferred: res.bytes_uploaded,
|
||||||
|
size: res.bytes_uploaded > 0 ? res.bytes_uploaded : q.size,
|
||||||
|
result: res,
|
||||||
|
}
|
||||||
|
: q
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errStr = typeof err === "string" ? err : String(err);
|
const errStr = typeof err === "string" ? err : String(err);
|
||||||
@@ -252,9 +344,120 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setCurrentUploadingIndex(0);
|
||||||
showToast("Queue processing completed.");
|
showToast("Queue processing completed.");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Account Management Actions ---
|
||||||
|
|
||||||
|
const handleSetDefaultAccount = async (accountId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke("set_default_account", { accountId });
|
||||||
|
showToast("Default account updated.");
|
||||||
|
await loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to set default account: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (accountId: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to remove account '${accountId}'?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await invoke("delete_account", { accountId });
|
||||||
|
showToast(`Account '${accountId}' removed.`);
|
||||||
|
await loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Failed to remove account: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartBrowserLogin = async () => {
|
||||||
|
if (!serverUrl.trim() || serverUrl.trim() === "https://") {
|
||||||
|
showToast("Please enter a valid Nextcloud server URL.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
const initPayload = await invoke<LoginFlowInitPayload>("initiate_login_flow", {
|
||||||
|
serverUrl: serverUrl.trim(),
|
||||||
|
});
|
||||||
|
setLoginFlowPayload(initPayload);
|
||||||
|
showToast("Browser opened for authorization.");
|
||||||
|
|
||||||
|
// Start Polling
|
||||||
|
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
|
||||||
|
|
||||||
|
pollingIntervalRef.current = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const account = await invoke<StoredAccount | null>("poll_login_flow", {
|
||||||
|
endpoint: initPayload.poll_endpoint,
|
||||||
|
token: initPayload.poll_token,
|
||||||
|
isDefault: setAsDefault,
|
||||||
|
label: accountLabel.trim() ? accountLabel.trim() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
setLoginFlowPayload(null);
|
||||||
|
showToast(`Successfully connected account: ${account.id}`);
|
||||||
|
await loadAccounts();
|
||||||
|
setSelectedAccount(account.id);
|
||||||
|
setServerUrl("https://");
|
||||||
|
setAccountLabel("");
|
||||||
|
}
|
||||||
|
} catch (pollErr) {
|
||||||
|
console.error("Polling error:", pollErr);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
} catch (e) {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
setLoginFlowPayload(null);
|
||||||
|
showToast(`Failed to initiate browser login: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelBrowserLogin = () => {
|
||||||
|
if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
setLoginFlowPayload(null);
|
||||||
|
showToast("Login cancelled.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!serverUrl.trim() || !manualUsername.trim() || !manualPassword.trim()) {
|
||||||
|
showToast("Please enter server URL, username, and password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAuthenticating(true);
|
||||||
|
try {
|
||||||
|
const account = await invoke<StoredAccount>("manual_login", {
|
||||||
|
serverUrl: serverUrl.trim(),
|
||||||
|
username: manualUsername.trim(),
|
||||||
|
appPassword: manualPassword.trim(),
|
||||||
|
isDefault: setAsDefault,
|
||||||
|
label: accountLabel.trim() ? accountLabel.trim() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
showToast(`Successfully connected account: ${account.id}`);
|
||||||
|
await loadAccounts();
|
||||||
|
setSelectedAccount(account.id);
|
||||||
|
setManualUsername("");
|
||||||
|
setManualPassword("");
|
||||||
|
setAccountLabel("");
|
||||||
|
setServerUrl("https://");
|
||||||
|
} catch (e) {
|
||||||
|
setIsAuthenticating(false);
|
||||||
|
showToast(`Failed to connect: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopy = (text: string, label: string) => {
|
const handleCopy = (text: string, label: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
showToast(`Copied ${label} to clipboard!`);
|
showToast(`Copied ${label} to clipboard!`);
|
||||||
@@ -276,7 +479,19 @@ export function App() {
|
|||||||
|
|
||||||
const pendingCount = fileQueue.filter((q) => q.status === "pending").length;
|
const pendingCount = fileQueue.filter((q) => q.status === "pending").length;
|
||||||
const doneCount = fileQueue.filter((q) => q.status === "done").length;
|
const doneCount = fileQueue.filter((q) => q.status === "done").length;
|
||||||
|
const errorCount = fileQueue.filter((q) => q.status === "error").length;
|
||||||
|
|
||||||
const totalQueueBytes = fileQueue.reduce((acc, q) => acc + (q.size || 0), 0);
|
const totalQueueBytes = fileQueue.reduce((acc, q) => acc + (q.size || 0), 0);
|
||||||
|
const totalTransferredBytes = fileQueue.reduce((acc, q) => {
|
||||||
|
if (q.status === "done") return acc + (q.size || 0);
|
||||||
|
if (q.status === "uploading") return acc + (q.bytesTransferred || 0);
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const overallPercent =
|
||||||
|
totalQueueBytes > 0
|
||||||
|
? Math.min(100, Math.round((totalTransferredBytes / totalQueueBytes) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
@@ -304,15 +519,45 @@ export function App() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: 13, color: "var(--danger)" }}>
|
<span
|
||||||
No Account Connected
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--danger)",
|
||||||
|
cursor: "pointer",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveTab("accounts")}
|
||||||
|
>
|
||||||
|
+ Connect Account
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<nav className="nav-tabs">
|
||||||
|
<button
|
||||||
|
className={`tab-button ${activeTab === "upload" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("upload")}
|
||||||
|
>
|
||||||
|
Upload & Queue
|
||||||
|
{fileQueue.length > 0 && (
|
||||||
|
<span className="tab-badge">{fileQueue.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-button ${activeTab === "accounts" ? "active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("accounts")}
|
||||||
|
>
|
||||||
|
Accounts & Auth
|
||||||
|
<span className="tab-badge">{accounts.length}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
|
{activeTab === "upload" && (
|
||||||
|
<>
|
||||||
{/* File Dropzone */}
|
{/* File Dropzone */}
|
||||||
<div
|
<div
|
||||||
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
|
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
|
||||||
@@ -329,7 +574,6 @@ export function App() {
|
|||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
||||||
const f = e.dataTransfer.files[i];
|
const f = e.dataTransfer.files[i];
|
||||||
// In desktop webview with path property
|
|
||||||
if ("path" in f && typeof (f as { path: string }).path === "string") {
|
if ("path" in f && typeof (f as { path: string }).path === "string") {
|
||||||
paths.push((f as { path: string }).path);
|
paths.push((f as { path: string }).path);
|
||||||
}
|
}
|
||||||
@@ -342,7 +586,9 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<div className="dropzone-icon">📥</div>
|
<div className="dropzone-icon">📥</div>
|
||||||
<div className="dropzone-title">
|
<div className="dropzone-title">
|
||||||
{isDragOver ? "Drop files now to add to queue" : "Drag and drop files here, or click to browse"}
|
{isDragOver
|
||||||
|
? "Drop files now to add to queue"
|
||||||
|
: "Drag and drop files here, or click to browse"}
|
||||||
</div>
|
</div>
|
||||||
<div className="dropzone-subtitle">
|
<div className="dropzone-subtitle">
|
||||||
Supports any files: documents, media, archives, and directories
|
Supports any files: documents, media, archives, and directories
|
||||||
@@ -357,6 +603,7 @@ export function App() {
|
|||||||
{fileQueue.length > 0 && (
|
{fileQueue.length > 0 && (
|
||||||
<span className="queue-summary-pill">
|
<span className="queue-summary-pill">
|
||||||
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
|
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
|
||||||
|
{errorCount > 0 && ` • ${errorCount} Failed`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,16 +629,50 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Total Progress Bar */}
|
||||||
|
{(isUploading || doneCount > 0) && fileQueue.length > 0 && (
|
||||||
|
<div className="overall-progress-container">
|
||||||
|
<div className="overall-progress-header">
|
||||||
|
<span className="overall-progress-title">
|
||||||
|
{isUploading
|
||||||
|
? `Uploading File ${currentUploadingIndex} of ${fileQueue.length}...`
|
||||||
|
: doneCount === fileQueue.length
|
||||||
|
? "All uploads complete!"
|
||||||
|
: "Upload batch progress"}
|
||||||
|
</span>
|
||||||
|
<span className="overall-progress-stats">
|
||||||
|
{formatBytes(totalTransferredBytes)} / {formatBytes(totalQueueBytes)} ({overallPercent}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-track">
|
||||||
|
<div
|
||||||
|
className={`progress-bar-fill ${isUploading ? "progress-bar-animated" : ""}`}
|
||||||
|
style={{ width: `${overallPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fileQueue.length === 0 ? (
|
{fileQueue.length === 0 ? (
|
||||||
<div className="empty-queue-hint">
|
<div className="empty-queue-hint">
|
||||||
Queue is empty. Drop files above or click to select files for uploading.
|
Queue is empty. Drop files above or click to select files for uploading.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="queue-item-list">
|
<div className="queue-item-list">
|
||||||
{fileQueue.map((item, index) => (
|
{fileQueue.map((item, index) => {
|
||||||
|
const itemPercent =
|
||||||
|
item.size > 0
|
||||||
|
? Math.min(100, Math.round((item.bytesTransferred / item.size) * 100))
|
||||||
|
: item.status === "done"
|
||||||
|
? 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`queue-item-row status-${item.status} ${draggedIndex === index ? "dragging" : ""}`}
|
className={`queue-item-row status-${item.status} ${
|
||||||
|
draggedIndex === index ? "dragging" : ""
|
||||||
|
}`}
|
||||||
draggable={!isUploading}
|
draggable={!isUploading}
|
||||||
onDragStart={() => handleDragStart(index)}
|
onDragStart={() => handleDragStart(index)}
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
@@ -405,8 +686,39 @@ export function App() {
|
|||||||
<div className="queue-item-info">
|
<div className="queue-item-info">
|
||||||
<div className="queue-item-name">{item.name}</div>
|
<div className="queue-item-name">{item.name}</div>
|
||||||
<div className="queue-item-path">{item.path}</div>
|
<div className="queue-item-path">{item.path}</div>
|
||||||
|
|
||||||
|
{/* Per-item progress bar when active or finished */}
|
||||||
|
{(item.status === "uploading" || item.status === "done") && (
|
||||||
|
<div className="item-progress-wrap">
|
||||||
|
<div className="item-progress-track">
|
||||||
|
<div
|
||||||
|
className={`item-progress-fill ${
|
||||||
|
item.status === "uploading" ? "item-progress-animated" : ""
|
||||||
|
}`}
|
||||||
|
style={{ width: `${item.status === "done" ? 100 : itemPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="item-progress-text">
|
||||||
|
{item.status === "done"
|
||||||
|
? "100%"
|
||||||
|
: `${itemPercent}% (${formatBytes(item.bytesTransferred)} / ${formatBytes(
|
||||||
|
item.size
|
||||||
|
)})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{item.errorMessage && (
|
{item.errorMessage && (
|
||||||
<div className="queue-item-error">Error: {item.errorMessage}</div>
|
<div className="queue-item-error">
|
||||||
|
<span>Error: {item.errorMessage}</span>
|
||||||
|
<button
|
||||||
|
className="btn-retry-inline"
|
||||||
|
onClick={() => handleRetryItem(item.id)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
↻ Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -445,7 +757,8 @@ export function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -492,12 +805,12 @@ export function App() {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ width: "100%", marginTop: 8 }}
|
style={{ width: "100%", marginTop: 8 }}
|
||||||
onClick={handleUploadQueue}
|
onClick={handleUploadQueue}
|
||||||
disabled={isUploading || pendingCount === 0}
|
disabled={isUploading || (pendingCount === 0 && errorCount === 0)}
|
||||||
>
|
>
|
||||||
{isUploading
|
{isUploading
|
||||||
? "Uploading Queue..."
|
? `Uploading Queue (${overallPercent}%)...`
|
||||||
: pendingCount > 0
|
: pendingCount > 0 || errorCount > 0
|
||||||
? `Upload Queue (${pendingCount} pending files)`
|
? `Upload Queue (${pendingCount + errorCount} files)`
|
||||||
: "All Files in Queue Uploaded"}
|
: "All Files in Queue Uploaded"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,7 +831,13 @@ export function App() {
|
|||||||
<span className="result-file-name">{r.file_name}</span>
|
<span className="result-file-name">{r.file_name}</span>
|
||||||
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
|
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 8 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Destination: {r.remote_path}
|
Destination: {r.remote_path}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,7 +864,9 @@ export function App() {
|
|||||||
<input className="link-input" readOnly value={r.direct_download_url} />
|
<input className="link-input" readOnly value={r.direct_download_url} />
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => handleCopy(r.direct_download_url!, "Direct Download Link")}
|
onClick={() =>
|
||||||
|
handleCopy(r.direct_download_url!, "Direct Download Link")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Copy Direct
|
Copy Direct
|
||||||
</button>
|
</button>
|
||||||
@@ -556,6 +877,265 @@ export function App() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "accounts" && (
|
||||||
|
<>
|
||||||
|
{/* Account List */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">
|
||||||
|
<span>Connected Accounts ({accounts.length})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<div className="empty-queue-hint">
|
||||||
|
No Nextcloud accounts stored yet. Add your account below to begin uploading.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="account-list">
|
||||||
|
{accounts.map((acc) => {
|
||||||
|
const isSelected = selectedAccount === acc.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={acc.id}
|
||||||
|
className={`account-card ${isSelected ? "active" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="account-card-header">
|
||||||
|
<div className="account-card-title">
|
||||||
|
<span className="account-name">
|
||||||
|
{acc.label ? acc.label : acc.username}
|
||||||
|
</span>
|
||||||
|
{acc.is_default && (
|
||||||
|
<span className="badge badge-default">DEFAULT</span>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<span className="badge badge-active">ACTIVE</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="account-card-actions">
|
||||||
|
{!isSelected && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setSelectedAccount(acc.id)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!acc.is_default && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleSetDefaultAccount(acc.id)}
|
||||||
|
>
|
||||||
|
Set Default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => handleDeleteAccount(acc.id)}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="account-card-meta">
|
||||||
|
<div>
|
||||||
|
<strong>User:</strong> {acc.username}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Server:</strong> {acc.server_url}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>ID:</strong> {acc.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add New Account Card */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Add Nextcloud Account</div>
|
||||||
|
|
||||||
|
<div className="auth-mode-toggle">
|
||||||
|
<button
|
||||||
|
className={`btn-mode ${authMode === "browser" ? "active" : ""}`}
|
||||||
|
onClick={() => setAuthMode("browser")}
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
>
|
||||||
|
🌐 Browser Login (Flow v2)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn-mode ${authMode === "manual" ? "active" : ""}`}
|
||||||
|
onClick={() => setAuthMode("manual")}
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
>
|
||||||
|
🔑 Manual App Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authMode === "browser" ? (
|
||||||
|
<div className="auth-form-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nextcloud Server URL</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="url"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => setServerUrl(e.target.value)}
|
||||||
|
placeholder="https://cloud.example.com"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Account Label (Optional)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={accountLabel}
|
||||||
|
onChange={(e) => setAccountLabel(e.target.value)}
|
||||||
|
placeholder="e.g. Work, Personal, Team Cloud"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setAsDefault}
|
||||||
|
onChange={(e) => setSetAsDefault(e.target.checked)}
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
/>
|
||||||
|
Set as default account
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loginFlowPayload ? (
|
||||||
|
<div className="polling-box">
|
||||||
|
<div className="polling-header">
|
||||||
|
<span className="spinner" />
|
||||||
|
<span>Waiting for browser authorization...</span>
|
||||||
|
</div>
|
||||||
|
<p className="polling-text">
|
||||||
|
A browser tab has been opened. Please log in and grant access to complete connection.
|
||||||
|
</p>
|
||||||
|
<div className="link-row" style={{ marginTop: 8 }}>
|
||||||
|
<input
|
||||||
|
className="link-input"
|
||||||
|
readOnly
|
||||||
|
value={loginFlowPayload.login_url}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenBrowser(loginFlowPayload.login_url)}
|
||||||
|
>
|
||||||
|
Reopen Browser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
onClick={handleCancelBrowserLogin}
|
||||||
|
>
|
||||||
|
Cancel Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: "100%", marginTop: 8 }}
|
||||||
|
onClick={handleStartBrowserLogin}
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
>
|
||||||
|
Authenticate in Browser (Login Flow v2)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="auth-form-content" onSubmit={handleManualLogin}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nextcloud Server URL</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="url"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => setServerUrl(e.target.value)}
|
||||||
|
placeholder="https://cloud.example.com"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Username</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={manualUsername}
|
||||||
|
onChange={(e) => setManualUsername(e.target.value)}
|
||||||
|
placeholder="admin or user@domain.com"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">App Password / Token</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={manualPassword}
|
||||||
|
onChange={(e) => setManualPassword(e.target.value)}
|
||||||
|
placeholder="Generated app password from Nextcloud security settings"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Account Label (Optional)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={accountLabel}
|
||||||
|
onChange={(e) => setAccountLabel(e.target.value)}
|
||||||
|
placeholder="e.g. Work, Personal"
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setAsDefault}
|
||||||
|
onChange={(e) => setSetAsDefault(e.target.checked)}
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
/>
|
||||||
|
Set as default account
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
style={{ width: "100%", marginTop: 8 }}
|
||||||
|
disabled={isAuthenticating}
|
||||||
|
>
|
||||||
|
{isAuthenticating ? "Verifying Credentials..." : "Connect Account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Floating Toast */}
|
{/* Floating Toast */}
|
||||||
|
|||||||
Reference in New Issue
Block a user