diff --git a/Tasks.md b/Tasks.md index 7e24330..dc0977f 100644 --- a/Tasks.md +++ b/Tasks.md @@ -66,12 +66,10 @@ This document defines the complete project roadmap and task tracking system for --- -## Tasks Summary (Rendered from task details) +## Tasks Summary (Rendered from task-details) | 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-017](#nut-017) | Implement Multi-Account Switching (GUI + CLI) | Triage | Integration | | [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-012](#nut-012) | Implement GUI (Tauri) Frontend | 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 | --- @@ -361,40 +361,40 @@ Add drag-and-drop file support and a queue system for multiple uploads. - NUT-012 - + ### Implement GUI Credential Management UI **ID:** NUT-014 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Add UI for managing accounts, logging in (including one-click browser authorization via Login Flow v2), logging out, and switching accounts. **Requirements:** -- [ ] Account list UI -- [ ] Browser-based login button (Login Flow v2) -- [ ] Manual login form (server/user/token) -- [ ] Logout button -- [ ] Switch account dropdown +- [x] Account list UI +- [x] Browser-based login button (Login Flow v2) +- [x] Manual login form (server/user/token) +- [x] Logout button +- [x] Switch account dropdown **Dependencies:** - NUT-007 - NUT-012 - + ### Implement GUI Upload Progress Bars **ID:** NUT-015 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Add per-file and total progress bars to the GUI. **Requirements:** -- [ ] Per-file progress -- [ ] Total progress -- [ ] Error display +- [x] Per-file progress +- [x] Total progress +- [x] Error display **Dependencies:** - NUT-003 diff --git a/gui/src-tauri/src/commands.rs b/gui/src-tauri/src/commands.rs index 6059dad..0f140b2 100644 --- a/gui/src-tauri/src/commands.rs +++ b/gui/src-tauri/src/commands.rs @@ -1,11 +1,13 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use std::sync::Arc; +use tauri::Emitter; use nextcloud_client::{ initiate_login_flow as api_initiate_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)] @@ -32,6 +34,13 @@ pub struct GuiUploadResult { pub direct_download_url: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GuiUploadProgressPayload { + pub file_path: String, + pub bytes_transferred: u64, + pub total_bytes: Option, +} + #[tauri::command] pub fn list_accounts() -> Result, String> { CredentialStore::list_accounts().map_err(|e| e.to_string()) @@ -184,6 +193,7 @@ pub fn get_file_info(file_path: String) -> Result { #[tauri::command] pub async fn upload_file( + app: tauri::AppHandle, file_path: String, remote_dir: String, create_share: bool, @@ -222,8 +232,23 @@ pub async fn upload_file( 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 - .upload_and_share(&local_path, &options, None) + .upload_and_share(&local_path, &options, Some(progress_cb)) .await .map_err(|e| e.to_string())?; @@ -239,7 +264,7 @@ pub async fn upload_file( #[cfg(test)] mod tests { - use super::{FileInfo, GuiUploadResult, LoginFlowInitPayload}; + use super::{FileInfo, GuiUploadProgressPayload, GuiUploadResult, LoginFlowInitPayload}; #[test] fn test_login_flow_payload_serialization() { @@ -267,6 +292,19 @@ mod tests { 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] fn test_gui_upload_result_serialization() { let res = GuiUploadResult { diff --git a/gui/src/App.css b/gui/src/App.css index 64a1273..39b067c 100644 --- a/gui/src/App.css +++ b/gui/src/App.css @@ -102,6 +102,47 @@ body { 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 */ .main-content { flex: 1; @@ -169,6 +210,71 @@ body { 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-title-meta { display: flex; @@ -204,7 +310,7 @@ body { display: flex; flex-direction: column; gap: 8px; - max-height: 280px; + max-height: 320px; overflow-y: auto; padding-right: 4px; } @@ -216,7 +322,7 @@ body { background: var(--bg-color); border: 1px solid var(--border-color); border-radius: var(--radius); - padding: 8px 12px; + padding: 10px 12px; transition: background-color 0.15s, border-color 0.15s; } @@ -265,10 +371,74 @@ body { 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 { font-size: 11px; 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 { @@ -354,6 +524,154 @@ body { 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-group { margin-bottom: 12px; diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 47bf646..79cfd10 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { openUrl } from "@tauri-apps/plugin-opener"; import "./App.css"; @@ -12,6 +13,12 @@ interface StoredAccount { is_default: boolean; } +interface LoginFlowInitPayload { + login_url: string; + poll_endpoint: string; + poll_token: string; +} + interface FileInfo { path: string; name: string; @@ -23,6 +30,7 @@ interface QueueItem { path: string; name: string; size: number; + bytesTransferred: number; status: "pending" | "uploading" | "done" | "error"; errorMessage?: string; result?: GuiUploadResult; @@ -37,7 +45,17 @@ interface GuiUploadResult { direct_download_url?: string; } +interface GuiUploadProgressPayload { + file_path: string; + bytes_transferred: number; + total_bytes?: number; +} + export function App() { + // Navigation + const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload"); + + // Account State const [accounts, setAccounts] = useState([]); const [selectedAccount, setSelectedAccount] = useState(""); const [toastMessage, setToastMessage] = useState(null); @@ -52,6 +70,22 @@ export function App() { const [createShare, setCreateShare] = useState(true); const [sharePassword, setSharePassword] = useState(""); const [isUploading, setIsUploading] = useState(false); + const [currentUploadingIndex, setCurrentUploadingIndex] = useState(0); + + // Add Account State + const [authMode, setAuthMode] = useState<"browser" | "manual">("browser"); + const [serverUrl, setServerUrl] = useState("https://"); + const [accountLabel, setAccountLabel] = useState(""); + const [setAsDefault, setSetAsDefault] = useState(true); + + // Manual Auth Fields + const [manualUsername, setManualUsername] = useState(""); + const [manualPassword, setManualPassword] = useState(""); + const [isAuthenticating, setIsAuthenticating] = useState(false); + + // Browser Flow State + const [loginFlowPayload, setLoginFlowPayload] = useState(null); + const pollingIntervalRef = useRef(null); const fileQueueRef = useRef(fileQueue); fileQueueRef.current = fileQueue; @@ -69,9 +103,9 @@ export function App() { setAccounts(list); const def = list.find((a) => a.is_default); if (def) { - setSelectedAccount(def.id); + setSelectedAccount((prev) => (list.some((a) => a.id === prev) ? prev : def.id)); } else if (list.length > 0) { - setSelectedAccount(list[0].id); + setSelectedAccount((prev) => (list.some((a) => a.id === prev) ? prev : list[0].id)); } else { setSelectedAccount(""); } @@ -97,6 +131,7 @@ export function App() { path: info.path, name: info.name, size: info.size, + bytesTransferred: 0, status: "pending", }); } catch { @@ -106,6 +141,7 @@ export function App() { path: p, name, size: 0, + bytesTransferred: 0, status: "pending", }); } @@ -121,31 +157,60 @@ export function App() { loadAccounts(); // Listen to Tauri Drag-and-Drop events from OS - let unlisten: (() => void) | undefined; + let unlistenDrag: (() => void) | undefined; try { const appWindow = getCurrentWebviewWindow(); - appWindow.onDragDropEvent((event) => { - if (event.payload.type === "enter" || event.payload.type === "over") { - setIsDragOver(true); - } else if (event.payload.type === "drop") { - setIsDragOver(false); - if (event.payload.paths && event.payload.paths.length > 0) { - addPathsToQueue(event.payload.paths); + appWindow + .onDragDropEvent((event) => { + if (event.payload.type === "enter" || event.payload.type === "over") { + setIsDragOver(true); + } else if (event.payload.type === "drop") { + setIsDragOver(false); + if (event.payload.paths && event.payload.paths.length > 0) { + addPathsToQueue(event.payload.paths); + } + } else if (event.payload.type === "leave") { + setIsDragOver(false); } - } else if (event.payload.type === "leave") { - setIsDragOver(false); - } - }).then((fn) => { - unlisten = fn; - }).catch((e) => { - console.warn("Tauri drag-drop listener not active in current mode:", e); - }); + }) + .then((fn) => { + unlistenDrag = fn; + }) + .catch((e) => { + console.warn("Tauri drag-drop listener not active in current mode:", e); + }); } catch (e) { console.warn("Tauri getCurrentWebviewWindow not available:", e); } + // Listen to upload-progress events from Tauri backend + let unlistenProgress: (() => void) | undefined; + listen("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 () => { - if (unlisten) unlisten(); + if (unlistenDrag) unlistenDrag(); + if (unlistenProgress) unlistenProgress(); + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } }; }, []); @@ -209,24 +274,41 @@ export function App() { 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 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) { showToast("No pending files in the queue to upload."); return; } 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; } setIsUploading(true); - for (const item of pendingItems) { - // Mark as uploading + for (let i = 0; i < pendingItems.length; i++) { + const item = pendingItems[i]; + setCurrentUploadingIndex(i + 1); + 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 { @@ -239,7 +321,17 @@ export function App() { }); 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) { const errStr = typeof err === "string" ? err : String(err); @@ -252,9 +344,120 @@ export function App() { } setIsUploading(false); + setCurrentUploadingIndex(0); 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("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("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("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) => { navigator.clipboard.writeText(text); showToast(`Copied ${label} to clipboard!`); @@ -276,7 +479,19 @@ export function App() { const pendingCount = fileQueue.filter((q) => q.status === "pending").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 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 (
@@ -304,257 +519,622 @@ export function App() {
) : ( - - No Account Connected + setActiveTab("accounts")} + > + + Connect Account )} + {/* Navigation Tabs */} + + {/* Main Content */}
- {/* File Dropzone */} -
{ - e.preventDefault(); - setIsDragOver(true); - }} - onDragLeave={() => setIsDragOver(false)} - onDrop={(e) => { - e.preventDefault(); - setIsDragOver(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - const paths: string[] = []; - for (let i = 0; i < e.dataTransfer.files.length; i++) { - const f = e.dataTransfer.files[i]; - // In desktop webview with path property - if ("path" in f && typeof (f as { path: string }).path === "string") { - paths.push((f as { path: string }).path); + {activeTab === "upload" && ( + <> + {/* File Dropzone */} +
{ + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragOver(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const paths: string[] = []; + for (let i = 0; i < e.dataTransfer.files.length; i++) { + const f = e.dataTransfer.files[i]; + if ("path" in f && typeof (f as { path: string }).path === "string") { + paths.push((f as { path: string }).path); + } + } + if (paths.length > 0) { + addPathsToQueue(paths); + } } - } - if (paths.length > 0) { - addPathsToQueue(paths); - } - } - }} - > -
📥
-
- {isDragOver ? "Drop files now to add to queue" : "Drag and drop files here, or click to browse"} -
-
- Supports any files: documents, media, archives, and directories -
-
- - {/* File Queue List */} -
-
-
- Upload Queue ({fileQueue.length}) - {fileQueue.length > 0 && ( - - {formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done - - )} + }} + > +
📥
+
+ {isDragOver + ? "Drop files now to add to queue" + : "Drag and drop files here, or click to browse"} +
+
+ Supports any files: documents, media, archives, and directories +
-
- {doneCount > 0 && ( - - )} - {fileQueue.length > 0 && ( - - )} -
-
- {fileQueue.length === 0 ? ( -
- Queue is empty. Drop files above or click to select files for uploading. -
- ) : ( -
- {fileQueue.map((item, index) => ( -
handleDragStart(index)} - onDragOver={(e) => handleDragOver(e, index)} - onDragEnd={handleDragEnd} - > -
- ⋮⋮ -
-
{index + 1}
+ {/* File Queue List */} +
+
+
+ Upload Queue ({fileQueue.length}) + {fileQueue.length > 0 && ( + + {formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done + {errorCount > 0 && ` • ${errorCount} Failed`} + + )} +
+
+ {doneCount > 0 && ( + + )} + {fileQueue.length > 0 && ( + + )} +
+
-
-
{item.name}
-
{item.path}
- {item.errorMessage && ( -
Error: {item.errorMessage}
- )} -
- -
{formatBytes(item.size)}
- -
- - {item.status.toUpperCase()} + {/* Overall Total Progress Bar */} + {(isUploading || doneCount > 0) && fileQueue.length > 0 && ( +
+
+ + {isUploading + ? `Uploading File ${currentUploadingIndex} of ${fileQueue.length}...` + : doneCount === fileQueue.length + ? "All uploads complete!" + : "Upload batch progress"} + + + {formatBytes(totalTransferredBytes)} / {formatBytes(totalQueueBytes)} ({overallPercent}%)
- -
- - - +
+
- ))} -
- )} -
+ )} - {/* Upload Settings */} -
-
Upload Settings
-
- - setRemoteDir(e.target.value)} - placeholder="Uploads or Projects/Folder" - /> -
+ {fileQueue.length === 0 ? ( +
+ Queue is empty. Drop files above or click to select files for uploading. +
+ ) : ( +
+ {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 ( +
handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + > +
+ ⋮⋮ +
+
{index + 1}
- {createShare && ( -
- - setSharePassword(e.target.value)} - placeholder="Leave empty for public access without password" - /> -
- )} +
+
{item.name}
+
{item.path}
- -
+ {/* Per-item progress bar when active or finished */} + {(item.status === "uploading" || item.status === "done") && ( +
+
+
+
+ + {item.status === "done" + ? "100%" + : `${itemPercent}% (${formatBytes(item.bytesTransferred)} / ${formatBytes( + item.size + )})`} + +
+ )} - {/* Completed Share Links & Results */} - {fileQueue.some((q) => q.result) && ( -
-
- Share Links & Direct Downloads -
- {fileQueue - .filter((q) => q.result) - .map((item) => { - const r = item.result!; - return ( -
-
- {r.file_name} - {formatBytes(r.bytes_uploaded)} -
-
- Destination: {r.remote_path} -
+ {item.errorMessage && ( +
+ Error: {item.errorMessage} + +
+ )} +
- {r.share_url && ( -
- - - +
{formatBytes(item.size)}
+ +
+ + {item.status.toUpperCase()} + +
+ +
+ + + +
- )} + ); + })} +
+ )} +
- {r.direct_download_url && ( -
- - +
+ + {/* Completed Share Links & Results */} + {fileQueue.some((q) => q.result) && ( +
+
+ Share Links & Direct Downloads +
+ {fileQueue + .filter((q) => q.result) + .map((item) => { + const r = item.result!; + return ( +
+
+ {r.file_name} + {formatBytes(r.bytes_uploaded)} +
+
- Copy Direct - + Destination: {r.remote_path} +
+ + {r.share_url && ( +
+ + + +
+ )} + + {r.direct_download_url && ( +
+ + +
+ )}
- )} + ); + })} +
+ )} + + )} + + {activeTab === "accounts" && ( + <> + {/* Account List */} +
+
+ Connected Accounts ({accounts.length}) +
+ + {accounts.length === 0 ? ( +
+ No Nextcloud accounts stored yet. Add your account below to begin uploading. +
+ ) : ( +
+ {accounts.map((acc) => { + const isSelected = selectedAccount === acc.id; + return ( +
+
+
+ + {acc.label ? acc.label : acc.username} + + {acc.is_default && ( + DEFAULT + )} + {isSelected && ( + ACTIVE + )} +
+ +
+ {!isSelected && ( + + )} + {!acc.is_default && ( + + )} + +
+
+ +
+
+ User: {acc.username} +
+
+ Server: {acc.server_url} +
+
+ ID: {acc.id} +
+
+
+ ); + })} +
+ )} +
+ + {/* Add New Account Card */} +
+
Add Nextcloud Account
+ +
+ + +
+ + {authMode === "browser" ? ( +
+
+ + setServerUrl(e.target.value)} + placeholder="https://cloud.example.com" + disabled={isAuthenticating} + />
- ); - })} -
+ +
+ + setAccountLabel(e.target.value)} + placeholder="e.g. Work, Personal, Team Cloud" + disabled={isAuthenticating} + /> +
+ +
+ +
+ + {loginFlowPayload ? ( +
+
+ + Waiting for browser authorization... +
+

+ A browser tab has been opened. Please log in and grant access to complete connection. +

+
+ + +
+ +
+ ) : ( + + )} +
+ ) : ( +
+
+ + setServerUrl(e.target.value)} + placeholder="https://cloud.example.com" + disabled={isAuthenticating} + required + /> +
+ +
+ + setManualUsername(e.target.value)} + placeholder="admin or user@domain.com" + disabled={isAuthenticating} + required + /> +
+ +
+ + setManualPassword(e.target.value)} + placeholder="Generated app password from Nextcloud security settings" + disabled={isAuthenticating} + required + /> +
+ +
+ + setAccountLabel(e.target.value)} + placeholder="e.g. Work, Personal" + disabled={isAuthenticating} + /> +
+ +
+ +
+ + +
+ )} +
+ )}