Implements progress bars in gui app.

This commit is contained in:
2026-08-23 23:08:01 -07:00
parent 6411fecb4f
commit a4838bf8d9
4 changed files with 1207 additions and 271 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 */}