Implements gui file queue and drag and drop.
This commit is contained in:
12
Tasks.md
12
Tasks.md
@@ -70,7 +70,6 @@ This document defines the complete project roadmap and task tracking system for
|
|||||||
|
|
||||||
| ID | Title | Status | Type |
|
| ID | Title | Status | Type |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Triage | Feature |
|
|
||||||
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature |
|
| [NUT-014](#nut-014) | Implement GUI Credential Management UI | Triage | Feature |
|
||||||
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature |
|
| [NUT-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature |
|
||||||
| [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Triage | Integration |
|
| [NUT-016](#nut-016) | Implement Shared Auth Token Reuse | Triage | Integration |
|
||||||
@@ -93,6 +92,7 @@ This document defines the complete project roadmap and task tracking system for
|
|||||||
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature |
|
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature |
|
||||||
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature |
|
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature |
|
||||||
| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature |
|
| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Fixed | Feature |
|
||||||
|
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -343,19 +343,19 @@ Implement the Tauri-based GUI application with a clean, responsive layout connec
|
|||||||
- NUT-006
|
- NUT-006
|
||||||
|
|
||||||
|
|
||||||
<a id="nut-013" class="task" data-status="triage" data-task-type="feature"></a>
|
<a id="nut-013" class="task" data-status="done" data-task-type="feature"></a>
|
||||||
### Implement GUI File Queue + Drag-and-Drop
|
### Implement GUI File Queue + Drag-and-Drop
|
||||||
**ID:** NUT-013
|
**ID:** NUT-013
|
||||||
**Status:** Triage
|
**Status:** Fixed
|
||||||
**Type:** Feature
|
**Type:** Feature
|
||||||
|
|
||||||
**Description:**
|
**Description:**
|
||||||
Add drag-and-drop file support and a queue system for multiple uploads.
|
Add drag-and-drop file support and a queue system for multiple uploads.
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
- [ ] Drag-and-drop area
|
- [x] Drag-and-drop area
|
||||||
- [ ] File queue list
|
- [x] File queue list
|
||||||
- [ ] Remove/reorder items
|
- [x] Remove/reorder items
|
||||||
|
|
||||||
**Dependencies:**
|
**Dependencies:**
|
||||||
- NUT-012
|
- NUT-012
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use nextcloud_client::{
|
use nextcloud_client::{
|
||||||
@@ -14,6 +15,13 @@ pub struct LoginFlowInitPayload {
|
|||||||
pub poll_token: String,
|
pub poll_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct FileInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct GuiUploadResult {
|
pub struct GuiUploadResult {
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
@@ -154,6 +162,26 @@ pub async fn select_files() -> Result<Vec<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_file_info(file_path: String) -> Result<FileInfo, String> {
|
||||||
|
let path = PathBuf::from(&file_path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("File '{}' does not exist", file_path));
|
||||||
|
}
|
||||||
|
let metadata = fs::metadata(&path).map_err(|e| e.to_string())?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("file")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(FileInfo {
|
||||||
|
path: file_path,
|
||||||
|
name,
|
||||||
|
size: metadata.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_file(
|
pub async fn upload_file(
|
||||||
file_path: String,
|
file_path: String,
|
||||||
@@ -211,7 +239,7 @@ pub async fn upload_file(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{GuiUploadResult, LoginFlowInitPayload};
|
use super::{FileInfo, GuiUploadResult, LoginFlowInitPayload};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_login_flow_payload_serialization() {
|
fn test_login_flow_payload_serialization() {
|
||||||
@@ -226,6 +254,19 @@ mod tests {
|
|||||||
assert_eq!(payload, deserialized);
|
assert_eq!(payload, deserialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_info_serialization() {
|
||||||
|
let file_info = FileInfo {
|
||||||
|
path: "/path/to/document.pdf".to_string(),
|
||||||
|
name: "document.pdf".to_string(),
|
||||||
|
size: 2048,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&file_info).unwrap();
|
||||||
|
let deserialized: FileInfo = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(file_info, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_gui_upload_result_serialization() {
|
fn test_gui_upload_result_serialization() {
|
||||||
let res = GuiUploadResult {
|
let res = GuiUploadResult {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn run() {
|
|||||||
commands::poll_login_flow,
|
commands::poll_login_flow,
|
||||||
commands::manual_login,
|
commands::manual_login,
|
||||||
commands::select_files,
|
commands::select_files,
|
||||||
|
commands::get_file_info,
|
||||||
commands::upload_file
|
commands::upload_file
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
243
gui/src/App.css
243
gui/src/App.css
@@ -13,6 +13,7 @@
|
|||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--danger-hover: #dc2626;
|
--danger-hover: #dc2626;
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
max-width: 900px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
@@ -101,37 +102,6 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Navigation Tabs */
|
|
||||||
.nav-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 14px;
|
|
||||||
margin-bottom: 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;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
color: var(--text-main);
|
|
||||||
background: var(--surface-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content Area */
|
/* Content Area */
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -139,6 +109,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
@@ -147,7 +118,6 @@ body {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -164,11 +134,10 @@ body {
|
|||||||
background: var(--surface-color);
|
background: var(--surface-color);
|
||||||
border: 2px dashed var(--border-color);
|
border: 2px dashed var(--border-color);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 24px 16px;
|
padding: 26px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 14px;
|
transition: all 0.2s ease-in-out;
|
||||||
transition: border-color 0.2s, background-color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-dropzone:hover {
|
.file-dropzone:hover {
|
||||||
@@ -176,6 +145,18 @@ body {
|
|||||||
background: rgba(56, 189, 248, 0.05);
|
background: rgba(56, 189, 248, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-dropzone.drag-over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
transform: scale(1.01);
|
||||||
|
box-shadow: 0 0 16px rgba(56, 189, 248, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.dropzone-title {
|
.dropzone-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -188,25 +169,189 @@ body {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File Chips */
|
/* Queue List */
|
||||||
.selected-files-list {
|
.queue-title-meta {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-file-chip {
|
|
||||||
background: var(--surface-card);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-summary-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--surface-card);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-actions {
|
||||||
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
max-width: 100%;
|
}
|
||||||
|
|
||||||
|
.empty-queue-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 24px 12px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: background-color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-row:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-row.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
border: 1px dashed var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-drag-handle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-index {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-path {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--danger);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 65px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-badge {
|
||||||
|
min-width: 75px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.status-pending {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.status-uploading {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.status-done {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
border: 1px solid var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.status-error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover:not(:disabled) {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-danger:hover:not(:disabled) {
|
||||||
|
color: white;
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form Controls */
|
/* Form Controls */
|
||||||
|
|||||||
497
gui/src/App.tsx
497
gui/src/App.tsx
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -11,6 +12,22 @@ interface StoredAccount {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
status: "pending" | "uploading" | "done" | "error";
|
||||||
|
errorMessage?: string;
|
||||||
|
result?: GuiUploadResult;
|
||||||
|
}
|
||||||
|
|
||||||
interface GuiUploadResult {
|
interface GuiUploadResult {
|
||||||
file_path: string;
|
file_path: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
@@ -21,18 +38,23 @@ interface GuiUploadResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload");
|
|
||||||
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);
|
||||||
|
|
||||||
|
// File Queue State
|
||||||
|
const [fileQueue, setFileQueue] = useState<QueueItem[]>([]);
|
||||||
|
const [isDragOver, setIsDragOver] = useState<boolean>(false);
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// Upload Form State
|
// Upload Form State
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
|
||||||
const [remoteDir, setRemoteDir] = useState<string>("Uploads");
|
const [remoteDir, setRemoteDir] = useState<string>("Uploads");
|
||||||
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 [uploadResults, setUploadResults] = useState<GuiUploadResult[]>([]);
|
|
||||||
|
const fileQueueRef = useRef(fileQueue);
|
||||||
|
fileQueueRef.current = fileQueue;
|
||||||
|
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToastMessage(msg);
|
setToastMessage(msg);
|
||||||
@@ -58,28 +80,139 @@ export function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addPathsToQueue = async (paths: string[]) => {
|
||||||
|
if (!paths || paths.length === 0) return;
|
||||||
|
|
||||||
|
const existingPaths = new Set(fileQueueRef.current.map((q) => q.path));
|
||||||
|
const newItems: QueueItem[] = [];
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
if (existingPaths.has(p)) continue;
|
||||||
|
existingPaths.add(p);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await invoke<FileInfo>("get_file_info", { filePath: p });
|
||||||
|
newItems.push({
|
||||||
|
id: `${p}-${Date.now()}-${Math.random()}`,
|
||||||
|
path: info.path,
|
||||||
|
name: info.name,
|
||||||
|
size: info.size,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const name = p.split(/[\\/]/).pop() || p;
|
||||||
|
newItems.push({
|
||||||
|
id: `${p}-${Date.now()}-${Math.random()}`,
|
||||||
|
path: p,
|
||||||
|
name,
|
||||||
|
size: 0,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newItems.length > 0) {
|
||||||
|
setFileQueue((prev) => [...prev, ...newItems]);
|
||||||
|
showToast(`Added ${newItems.length} file(s) to queue.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
|
||||||
|
// Listen to Tauri Drag-and-Drop events from OS
|
||||||
|
let unlisten: (() => 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);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Tauri getCurrentWebviewWindow not available:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectFiles = async () => {
|
const handleSelectFiles = async () => {
|
||||||
try {
|
try {
|
||||||
const files = await invoke<string[]>("select_files");
|
const files = await invoke<string[]>("select_files");
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
setSelectedFiles((prev) => Array.from(new Set([...prev, ...files])));
|
addPathsToQueue(files);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Error picking files: ${e}`);
|
showToast(`Error picking files: ${e}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSelectedFile = (path: string) => {
|
const handleRemoveQueueItem = (id: string) => {
|
||||||
setSelectedFiles((prev) => prev.filter((p) => p !== path));
|
setFileQueue((prev) => prev.filter((item) => item.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleMoveQueueItem = (index: number, direction: "up" | "down") => {
|
||||||
if (selectedFiles.length === 0) {
|
setFileQueue((prev) => {
|
||||||
showToast("Please select at least one file to upload.");
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= prev.length) return prev;
|
||||||
|
const copy = [...prev];
|
||||||
|
const temp = copy[index];
|
||||||
|
copy[index] = copy[targetIndex];
|
||||||
|
copy[targetIndex] = temp;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return;
|
||||||
|
|
||||||
|
setFileQueue((prev) => {
|
||||||
|
const copy = [...prev];
|
||||||
|
const [removed] = copy.splice(draggedIndex, 1);
|
||||||
|
copy.splice(index, 0, removed);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCompleted = () => {
|
||||||
|
setFileQueue((prev) => prev.filter((item) => item.status !== "done"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
if (isUploading) {
|
||||||
|
showToast("Cannot clear queue while uploads are in progress.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileQueue([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadQueue = async () => {
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,27 +222,37 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
const results: GuiUploadResult[] = [];
|
|
||||||
|
|
||||||
for (const filePath of selectedFiles) {
|
for (const item of pendingItems) {
|
||||||
|
// Mark as uploading
|
||||||
|
setFileQueue((prev) =>
|
||||||
|
prev.map((q) => (q.id === item.id ? { ...q, status: "uploading", errorMessage: undefined } : q))
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await invoke<GuiUploadResult>("upload_file", {
|
const res = await invoke<GuiUploadResult>("upload_file", {
|
||||||
filePath,
|
filePath: item.path,
|
||||||
remoteDir,
|
remoteDir,
|
||||||
createShare,
|
createShare,
|
||||||
sharePassword: sharePassword.trim() ? sharePassword.trim() : null,
|
sharePassword: sharePassword.trim() ? sharePassword.trim() : null,
|
||||||
accountId: selectedAccount ? selectedAccount : null,
|
accountId: selectedAccount ? selectedAccount : null,
|
||||||
});
|
});
|
||||||
results.push(res);
|
|
||||||
} catch (e) {
|
setFileQueue((prev) =>
|
||||||
showToast(`Failed to upload ${filePath}: ${e}`);
|
prev.map((q) => (q.id === item.id ? { ...q, status: "done", result: res } : q))
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errStr = typeof err === "string" ? err : String(err);
|
||||||
|
setFileQueue((prev) =>
|
||||||
|
prev.map((q) =>
|
||||||
|
q.id === item.id ? { ...q, status: "error", errorMessage: errStr } : q
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadResults(results);
|
|
||||||
setSelectedFiles([]);
|
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
showToast(`Uploaded ${results.length} file(s) successfully!`);
|
showToast("Queue processing completed.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (text: string, label: string) => {
|
const handleCopy = (text: string, label: string) => {
|
||||||
@@ -131,6 +274,10 @@ export function App() {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pendingCount = fileQueue.filter((q) => q.status === "pending").length;
|
||||||
|
const doneCount = fileQueue.filter((q) => q.status === "done").length;
|
||||||
|
const totalQueueBytes = fileQueue.reduce((acc, q) => acc + (q.size || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -164,124 +311,209 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
|
||||||
<nav className="nav-tabs">
|
|
||||||
<button
|
|
||||||
className={`tab-button ${activeTab === "upload" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("upload")}
|
|
||||||
>
|
|
||||||
Upload & Share
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
{activeTab === "upload" && (
|
{/* File Dropzone */}
|
||||||
<div>
|
<div
|
||||||
{/* File Dropzone / Picker */}
|
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
|
||||||
<div className="file-dropzone" onClick={handleSelectFiles}>
|
onClick={handleSelectFiles}
|
||||||
<div className="dropzone-title">
|
onDragOver={(e) => {
|
||||||
📁 Click here to select file(s) for upload
|
e.preventDefault();
|
||||||
</div>
|
setIsDragOver(true);
|
||||||
<div className="dropzone-subtitle">
|
}}
|
||||||
Upload any document, image, video, or archive to your Nextcloud
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
</div>
|
onDrop={(e) => {
|
||||||
</div>
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paths.length > 0) {
|
||||||
|
addPathsToQueue(paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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"}
|
||||||
|
</div>
|
||||||
|
<div className="dropzone-subtitle">
|
||||||
|
Supports any files: documents, media, archives, and directories
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Selected Files List */}
|
{/* File Queue List */}
|
||||||
{selectedFiles.length > 0 && (
|
<div className="card">
|
||||||
<div className="card">
|
<div className="card-title">
|
||||||
<div className="card-title">
|
<div className="queue-title-meta">
|
||||||
<span>Selected Files ({selectedFiles.length})</span>
|
<span>Upload Queue ({fileQueue.length})</span>
|
||||||
<button
|
{fileQueue.length > 0 && (
|
||||||
className="btn btn-secondary btn-sm"
|
<span className="queue-summary-pill">
|
||||||
onClick={() => setSelectedFiles([])}
|
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
|
||||||
>
|
</span>
|
||||||
Clear All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="selected-files-list">
|
|
||||||
{selectedFiles.map((file) => (
|
|
||||||
<div key={file} className="selected-file-chip">
|
|
||||||
<span>{file}</span>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm"
|
|
||||||
style={{ border: "none", color: "var(--danger)" }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRemoveSelectedFile(file);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload Options Card */}
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">Upload Settings</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Destination Folder on Nextcloud</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
type="text"
|
|
||||||
value={remoteDir}
|
|
||||||
onChange={(e) => setRemoteDir(e.target.value)}
|
|
||||||
placeholder="Uploads or Projects/Folder"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createShare}
|
|
||||||
onChange={(e) => setCreateShare(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Automatically generate public share link after upload
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createShare && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Optional Share Password</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
type="password"
|
|
||||||
value={sharePassword}
|
|
||||||
onChange={(e) => setSharePassword(e.target.value)}
|
|
||||||
placeholder="Leave empty for public access without password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ width: "100%", marginTop: 8 }}
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={isUploading || selectedFiles.length === 0}
|
|
||||||
>
|
|
||||||
{isUploading ? "Uploading..." : `Upload ${selectedFiles.length > 0 ? `(${selectedFiles.length} files)` : ""}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="queue-actions">
|
||||||
|
{doneCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={handleClearCompleted}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Clear Completed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fileQueue.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Upload Results */}
|
{fileQueue.length === 0 ? (
|
||||||
{uploadResults.length > 0 && (
|
<div className="empty-queue-hint">
|
||||||
<div className="card">
|
Queue is empty. Drop files above or click to select files for uploading.
|
||||||
<div className="card-title">
|
</div>
|
||||||
<span>Upload Results</span>
|
) : (
|
||||||
<button
|
<div className="queue-item-list">
|
||||||
className="btn btn-secondary btn-sm"
|
{fileQueue.map((item, index) => (
|
||||||
onClick={() => setUploadResults([])}
|
<div
|
||||||
>
|
key={item.id}
|
||||||
Dismiss
|
className={`queue-item-row status-${item.status} ${draggedIndex === index ? "dragging" : ""}`}
|
||||||
</button>
|
draggable={!isUploading}
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="queue-item-drag-handle" title="Drag to reorder">
|
||||||
|
⋮⋮
|
||||||
|
</div>
|
||||||
|
<div className="queue-item-index">{index + 1}</div>
|
||||||
|
|
||||||
|
<div className="queue-item-info">
|
||||||
|
<div className="queue-item-name">{item.name}</div>
|
||||||
|
<div className="queue-item-path">{item.path}</div>
|
||||||
|
{item.errorMessage && (
|
||||||
|
<div className="queue-item-error">Error: {item.errorMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="queue-item-size">{formatBytes(item.size)}</div>
|
||||||
|
|
||||||
|
<div className="queue-item-badge">
|
||||||
|
<span className={`status-tag status-${item.status}`}>
|
||||||
|
{item.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="queue-item-controls">
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
title="Move Up"
|
||||||
|
disabled={isUploading || index === 0}
|
||||||
|
onClick={() => handleMoveQueueItem(index, "up")}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
title="Move Down"
|
||||||
|
disabled={isUploading || index === fileQueue.length - 1}
|
||||||
|
onClick={() => handleMoveQueueItem(index, "down")}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon btn-icon-danger"
|
||||||
|
title="Remove from queue"
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={() => handleRemoveQueueItem(item.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadResults.map((r, i) => (
|
))}
|
||||||
<div key={i} className="result-card">
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Settings */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Upload Settings</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Destination Folder on Nextcloud</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={remoteDir}
|
||||||
|
onChange={(e) => setRemoteDir(e.target.value)}
|
||||||
|
placeholder="Uploads or Projects/Folder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createShare}
|
||||||
|
onChange={(e) => setCreateShare(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Automatically generate public share link after upload
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createShare && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Optional Share Password</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={sharePassword}
|
||||||
|
onChange={(e) => setSharePassword(e.target.value)}
|
||||||
|
placeholder="Leave empty for public access without password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: "100%", marginTop: 8 }}
|
||||||
|
onClick={handleUploadQueue}
|
||||||
|
disabled={isUploading || pendingCount === 0}
|
||||||
|
>
|
||||||
|
{isUploading
|
||||||
|
? "Uploading Queue..."
|
||||||
|
: pendingCount > 0
|
||||||
|
? `Upload Queue (${pendingCount} pending files)`
|
||||||
|
: "All Files in Queue Uploaded"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Share Links & Results */}
|
||||||
|
{fileQueue.some((q) => q.result) && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">
|
||||||
|
<span>Share Links & Direct Downloads</span>
|
||||||
|
</div>
|
||||||
|
{fileQueue
|
||||||
|
.filter((q) => q.result)
|
||||||
|
.map((item) => {
|
||||||
|
const r = item.result!;
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="result-card">
|
||||||
<div className="result-header">
|
<div className="result-header">
|
||||||
<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>
|
||||||
@@ -320,9 +552,8 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user