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>
|
||||
## 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
|
||||
|
||||
|
||||
<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
|
||||
**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
|
||||
|
||||
|
||||
<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
|
||||
**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
|
||||
|
||||
@@ -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<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]
|
||||
pub fn list_accounts() -> Result<Vec<StoredAccount>, 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]
|
||||
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 {
|
||||
|
||||
324
gui/src/App.css
324
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;
|
||||
|
||||
636
gui/src/App.tsx
636
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<StoredAccount[]>([]);
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
@@ -52,6 +70,22 @@ export function App() {
|
||||
const [createShare, setCreateShare] = useState<boolean>(true);
|
||||
const [sharePassword, setSharePassword] = useState<string>("");
|
||||
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);
|
||||
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,10 +157,11 @@ 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) => {
|
||||
appWindow
|
||||
.onDragDropEvent((event) => {
|
||||
if (event.payload.type === "enter" || event.payload.type === "over") {
|
||||
setIsDragOver(true);
|
||||
} else if (event.payload.type === "drop") {
|
||||
@@ -135,17 +172,45 @@ export function App() {
|
||||
} else if (event.payload.type === "leave") {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
}).catch((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<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 () => {
|
||||
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<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) => {
|
||||
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 (
|
||||
<div className="app-container">
|
||||
@@ -304,15 +519,45 @@ export function App() {
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--danger)" }}>
|
||||
No Account Connected
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--danger)",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
onClick={() => setActiveTab("accounts")}
|
||||
>
|
||||
+ Connect Account
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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 className="main-content">
|
||||
{activeTab === "upload" && (
|
||||
<>
|
||||
{/* File Dropzone */}
|
||||
<div
|
||||
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
|
||||
@@ -329,7 +574,6 @@ export function App() {
|
||||
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);
|
||||
}
|
||||
@@ -342,7 +586,9 @@ export function App() {
|
||||
>
|
||||
<div className="dropzone-icon">📥</div>
|
||||
<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 className="dropzone-subtitle">
|
||||
Supports any files: documents, media, archives, and directories
|
||||
@@ -357,6 +603,7 @@ export function App() {
|
||||
{fileQueue.length > 0 && (
|
||||
<span className="queue-summary-pill">
|
||||
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
|
||||
{errorCount > 0 && ` • ${errorCount} Failed`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -382,16 +629,50 @@ export function App() {
|
||||
</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 ? (
|
||||
<div className="empty-queue-hint">
|
||||
Queue is empty. Drop files above or click to select files for uploading.
|
||||
</div>
|
||||
) : (
|
||||
<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
|
||||
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}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
@@ -405,8 +686,39 @@ export function App() {
|
||||
<div className="queue-item-info">
|
||||
<div className="queue-item-name">{item.name}</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 && (
|
||||
<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>
|
||||
|
||||
@@ -445,7 +757,8 @@ export function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -492,12 +805,12 @@ export function App() {
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
onClick={handleUploadQueue}
|
||||
disabled={isUploading || pendingCount === 0}
|
||||
disabled={isUploading || (pendingCount === 0 && errorCount === 0)}
|
||||
>
|
||||
{isUploading
|
||||
? "Uploading Queue..."
|
||||
: pendingCount > 0
|
||||
? `Upload Queue (${pendingCount} pending files)`
|
||||
? `Uploading Queue (${overallPercent}%)...`
|
||||
: pendingCount > 0 || errorCount > 0
|
||||
? `Upload Queue (${pendingCount + errorCount} files)`
|
||||
: "All Files in Queue Uploaded"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -518,7 +831,13 @@ export function App() {
|
||||
<span className="result-file-name">{r.file_name}</span>
|
||||
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Destination: {r.remote_path}
|
||||
</div>
|
||||
|
||||
@@ -545,7 +864,9 @@ export function App() {
|
||||
<input className="link-input" readOnly value={r.direct_download_url} />
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleCopy(r.direct_download_url!, "Direct Download Link")}
|
||||
onClick={() =>
|
||||
handleCopy(r.direct_download_url!, "Direct Download Link")
|
||||
}
|
||||
>
|
||||
Copy Direct
|
||||
</button>
|
||||
@@ -556,6 +877,265 @@ export function App() {
|
||||
})}
|
||||
</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>
|
||||
|
||||
{/* Floating Toast */}
|
||||
|
||||
Reference in New Issue
Block a user