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 |
|
||||
|---|---|---|---|
|
||||
| [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-015](#nut-015) | Implement GUI Upload Progress Bars | Triage | Feature |
|
||||
| [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-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-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
|
||||
|
||||
|
||||
<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
|
||||
**ID:** NUT-013
|
||||
**Status:** Triage
|
||||
**Status:** Fixed
|
||||
**Type:** Feature
|
||||
|
||||
**Description:**
|
||||
Add drag-and-drop file support and a queue system for multiple uploads.
|
||||
|
||||
**Requirements:**
|
||||
- [ ] Drag-and-drop area
|
||||
- [ ] File queue list
|
||||
- [ ] Remove/reorder items
|
||||
- [x] Drag-and-drop area
|
||||
- [x] File queue list
|
||||
- [x] Remove/reorder items
|
||||
|
||||
**Dependencies:**
|
||||
- NUT-012
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nextcloud_client::{
|
||||
@@ -14,6 +15,13 @@ pub struct LoginFlowInitPayload {
|
||||
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)]
|
||||
pub struct GuiUploadResult {
|
||||
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]
|
||||
pub async fn upload_file(
|
||||
file_path: String,
|
||||
@@ -211,7 +239,7 @@ pub async fn upload_file(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GuiUploadResult, LoginFlowInitPayload};
|
||||
use super::{FileInfo, GuiUploadResult, LoginFlowInitPayload};
|
||||
|
||||
#[test]
|
||||
fn test_login_flow_payload_serialization() {
|
||||
@@ -226,6 +254,19 @@ mod tests {
|
||||
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]
|
||||
fn test_gui_upload_result_serialization() {
|
||||
let res = GuiUploadResult {
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn run() {
|
||||
commands::poll_login_flow,
|
||||
commands::manual_login,
|
||||
commands::select_files,
|
||||
commands::get_file_info,
|
||||
commands::upload_file
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
243
gui/src/App.css
243
gui/src/App.css
@@ -13,6 +13,7 @@
|
||||
--danger: #ef4444;
|
||||
--danger-hover: #dc2626;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
max-width: 900px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
@@ -101,37 +102,6 @@ body {
|
||||
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 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
@@ -139,6 +109,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
@@ -147,7 +118,6 @@ body {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -164,11 +134,10 @@ body {
|
||||
background: var(--surface-color);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 16px;
|
||||
padding: 26px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 14px;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.file-dropzone:hover {
|
||||
@@ -176,6 +145,18 @@ body {
|
||||
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 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
@@ -188,25 +169,189 @@ body {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* File Chips */
|
||||
.selected-files-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selected-file-chip {
|
||||
background: var(--surface-card);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
/* Queue List */
|
||||
.queue-title-meta {
|
||||
display: flex;
|
||||
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;
|
||||
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;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-item-path {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
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 */
|
||||
|
||||
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 { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "./App.css";
|
||||
|
||||
@@ -11,6 +12,22 @@ interface StoredAccount {
|
||||
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 {
|
||||
file_path: string;
|
||||
file_name: string;
|
||||
@@ -21,18 +38,23 @@ interface GuiUploadResult {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [activeTab, setActiveTab] = useState<"upload" | "accounts">("upload");
|
||||
const [accounts, setAccounts] = useState<StoredAccount[]>([]);
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
||||
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
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [remoteDir, setRemoteDir] = useState<string>("Uploads");
|
||||
const [createShare, setCreateShare] = useState<boolean>(true);
|
||||
const [sharePassword, setSharePassword] = useState<string>("");
|
||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||
const [uploadResults, setUploadResults] = useState<GuiUploadResult[]>([]);
|
||||
|
||||
const fileQueueRef = useRef(fileQueue);
|
||||
fileQueueRef.current = fileQueue;
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
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(() => {
|
||||
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 () => {
|
||||
try {
|
||||
const files = await invoke<string[]>("select_files");
|
||||
if (files && files.length > 0) {
|
||||
setSelectedFiles((prev) => Array.from(new Set([...prev, ...files])));
|
||||
addPathsToQueue(files);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Error picking files: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSelectedFile = (path: string) => {
|
||||
setSelectedFiles((prev) => prev.filter((p) => p !== path));
|
||||
const handleRemoveQueueItem = (id: string) => {
|
||||
setFileQueue((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
showToast("Please select at least one file to upload.");
|
||||
const handleMoveQueueItem = (index: number, direction: "up" | "down") => {
|
||||
setFileQueue((prev) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -89,27 +222,37 @@ export function App() {
|
||||
}
|
||||
|
||||
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 {
|
||||
const res = await invoke<GuiUploadResult>("upload_file", {
|
||||
filePath,
|
||||
filePath: item.path,
|
||||
remoteDir,
|
||||
createShare,
|
||||
sharePassword: sharePassword.trim() ? sharePassword.trim() : null,
|
||||
accountId: selectedAccount ? selectedAccount : null,
|
||||
});
|
||||
results.push(res);
|
||||
} catch (e) {
|
||||
showToast(`Failed to upload ${filePath}: ${e}`);
|
||||
|
||||
setFileQueue((prev) =>
|
||||
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);
|
||||
showToast(`Uploaded ${results.length} file(s) successfully!`);
|
||||
showToast("Queue processing completed.");
|
||||
};
|
||||
|
||||
const handleCopy = (text: string, label: string) => {
|
||||
@@ -131,6 +274,10 @@ export function App() {
|
||||
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 (
|
||||
<div className="app-container">
|
||||
{/* Header */}
|
||||
@@ -164,124 +311,209 @@ export function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="nav-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === "upload" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("upload")}
|
||||
>
|
||||
Upload & Share
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="main-content">
|
||||
{activeTab === "upload" && (
|
||||
<div>
|
||||
{/* File Dropzone / Picker */}
|
||||
<div className="file-dropzone" onClick={handleSelectFiles}>
|
||||
<div className="dropzone-title">
|
||||
📁 Click here to select file(s) for upload
|
||||
</div>
|
||||
<div className="dropzone-subtitle">
|
||||
Upload any document, image, video, or archive to your Nextcloud
|
||||
</div>
|
||||
</div>
|
||||
{/* File Dropzone */}
|
||||
<div
|
||||
className={`file-dropzone ${isDragOver ? "drag-over" : ""}`}
|
||||
onClick={handleSelectFiles}
|
||||
onDragOver={(e) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>Selected Files ({selectedFiles.length})</span>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setSelectedFiles([])}
|
||||
>
|
||||
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>
|
||||
{/* File Queue List */}
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<div className="queue-title-meta">
|
||||
<span>Upload Queue ({fileQueue.length})</span>
|
||||
{fileQueue.length > 0 && (
|
||||
<span className="queue-summary-pill">
|
||||
{formatBytes(totalQueueBytes)} • {pendingCount} Pending • {doneCount} Done
|
||||
</span>
|
||||
)}
|
||||
|
||||
<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 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 */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>Upload Results</span>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setUploadResults([])}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
{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) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`queue-item-row status-${item.status} ${draggedIndex === index ? "dragging" : ""}`}
|
||||
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>
|
||||
{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">
|
||||
<span className="result-file-name">{r.file_name}</span>
|
||||
<span className="result-size">{formatBytes(r.bytes_uploaded)}</span>
|
||||
@@ -320,9 +552,8 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user