Implements basic gui app.

This commit is contained in:
2026-08-23 19:06:34 -07:00
parent 4c5db3f950
commit ae84a31ed6
8 changed files with 1266 additions and 146 deletions

View File

@@ -18,8 +18,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
nextcloud_client = { path = "../../nextcloud_client" }
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
rfd = { version = "0.15", default-features = false, features = ["tokio"] }
open = "5"

View File

@@ -0,0 +1,244 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use nextcloud_client::{
initiate_login_flow as api_initiate_login_flow,
poll_login_flow as api_poll_login_flow,
ClientConfig, CredentialStore, NextcloudClient, StoredAccount, UploadOptions,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LoginFlowInitPayload {
pub login_url: String,
pub poll_endpoint: String,
pub poll_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GuiUploadResult {
pub file_path: String,
pub file_name: String,
pub remote_path: String,
pub bytes_uploaded: u64,
pub share_url: Option<String>,
pub direct_download_url: Option<String>,
}
#[tauri::command]
pub fn list_accounts() -> Result<Vec<StoredAccount>, String> {
CredentialStore::list_accounts().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_default_account() -> Result<Option<StoredAccount>, String> {
let accounts = CredentialStore::list_accounts().map_err(|e| e.to_string())?;
Ok(accounts.into_iter().find(|a| a.is_default))
}
#[tauri::command]
pub fn set_default_account(account_id: String) -> Result<(), String> {
CredentialStore::set_default_account(&account_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_account(account_id: String) -> Result<(), String> {
CredentialStore::delete_account(&account_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn initiate_login_flow(server_url: String) -> Result<LoginFlowInitPayload, String> {
let normalized = ClientConfig::normalize_url(&server_url).map_err(|e| e.to_string())?;
let http = reqwest::Client::new();
let flow = api_initiate_login_flow(&http, &normalized)
.await
.map_err(|e| e.to_string())?;
// Attempt to launch desktop browser automatically
let _ = open::that(&flow.login);
Ok(LoginFlowInitPayload {
login_url: flow.login,
poll_endpoint: flow.poll.endpoint,
poll_token: flow.poll.token,
})
}
#[tauri::command]
pub async fn poll_login_flow(
endpoint: String,
token: String,
is_default: bool,
label: Option<String>,
) -> Result<Option<StoredAccount>, String> {
let http = reqwest::Client::new();
let poll_res = api_poll_login_flow(&http, &endpoint, &token)
.await
.map_err(|e| e.to_string())?;
if let Some(creds) = poll_res {
let mut account = CredentialStore::save_account(
&creds.server,
&creds.login_name,
&creds.app_password,
is_default,
)
.map_err(|e| e.to_string())?;
if let Some(lbl) = label {
account.label = Some(lbl);
let mut accounts = CredentialStore::list_accounts().map_err(|e| e.to_string())?;
if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) {
a.label = account.label.clone();
}
CredentialStore::save_accounts(&accounts).map_err(|e| e.to_string())?;
}
Ok(Some(account))
} else {
Ok(None)
}
}
#[tauri::command]
pub async fn manual_login(
server_url: String,
username: String,
app_password: String,
is_default: bool,
label: Option<String>,
) -> Result<StoredAccount, String> {
let normalized = ClientConfig::normalize_url(&server_url).map_err(|e| e.to_string())?;
let config = ClientConfig::with_credentials(normalized.as_str(), &username, &app_password)
.map_err(|e| e.to_string())?;
let client = NextcloudClient::new(config).map_err(|e| e.to_string())?;
// Verify connectivity and credentials
client.test_connection().await.map_err(|e| e.to_string())?;
let mut account = CredentialStore::save_account(
normalized.as_str(),
&username,
&app_password,
is_default,
)
.map_err(|e| e.to_string())?;
if let Some(lbl) = label {
account.label = Some(lbl);
let mut accounts = CredentialStore::list_accounts().map_err(|e| e.to_string())?;
if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) {
a.label = account.label.clone();
}
CredentialStore::save_accounts(&accounts).map_err(|e| e.to_string())?;
}
Ok(account)
}
#[tauri::command]
pub async fn select_files() -> Result<Vec<String>, String> {
let files = rfd::AsyncFileDialog::new()
.set_title("Select Files to Upload to Nextcloud")
.pick_files()
.await;
match files {
Some(handles) => {
let paths = handles
.into_iter()
.map(|h| h.path().to_string_lossy().to_string())
.collect();
Ok(paths)
}
None => Ok(Vec::new()),
}
}
#[tauri::command]
pub async fn upload_file(
file_path: String,
remote_dir: String,
create_share: bool,
share_password: Option<String>,
account_id: Option<String>,
) -> Result<GuiUploadResult, String> {
let client = match account_id {
Some(id) if !id.is_empty() => {
CredentialStore::create_client_for_account(&id).map_err(|e| e.to_string())?
}
_ => CredentialStore::create_client_for_default().map_err(|e| e.to_string())?,
};
let local_path = PathBuf::from(&file_path);
if !local_path.exists() {
return Err(format!("File '{}' does not exist", file_path));
}
let file_name = local_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let clean_dir = remote_dir.trim_matches('/');
let remote_path = if clean_dir.is_empty() {
file_name.clone()
} else {
format!("{clean_dir}/{file_name}")
};
let options = UploadOptions {
remote_path: remote_path.clone(),
create_share,
share_password,
overwrite: true,
};
let res = client
.upload_and_share(&local_path, &options, None)
.await
.map_err(|e| e.to_string())?;
Ok(GuiUploadResult {
file_path,
file_name,
remote_path,
bytes_uploaded: res.bytes_uploaded,
share_url: res.share_url,
direct_download_url: res.direct_download_url,
})
}
#[cfg(test)]
mod tests {
use super::{GuiUploadResult, LoginFlowInitPayload};
#[test]
fn test_login_flow_payload_serialization() {
let payload = LoginFlowInitPayload {
login_url: "https://cloud.example.com/index.php/login/v2/flow/123".to_string(),
poll_endpoint: "https://cloud.example.com/index.php/login/v2/poll".to_string(),
poll_token: "abc-token".to_string(),
};
let json = serde_json::to_string(&payload).unwrap();
let deserialized: LoginFlowInitPayload = serde_json::from_str(&json).unwrap();
assert_eq!(payload, deserialized);
}
#[test]
fn test_gui_upload_result_serialization() {
let res = GuiUploadResult {
file_path: "/tmp/test.pdf".to_string(),
file_name: "test.pdf".to_string(),
remote_path: "Uploads/test.pdf".to_string(),
bytes_uploaded: 1024,
share_url: Some("https://cloud.example.com/s/ABC".to_string()),
direct_download_url: Some("https://cloud.example.com/index.php/s/ABC/download".to_string()),
};
let json = serde_json::to_string(&res).unwrap();
let deserialized: GuiUploadResult = serde_json::from_str(&json).unwrap();
assert_eq!(res, deserialized);
}
}

View File

@@ -1,14 +1,20 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
pub mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![
commands::list_accounts,
commands::get_default_account,
commands::set_default_account,
commands::delete_account,
commands::initiate_login_flow,
commands::poll_login_flow,
commands::manual_login,
commands::select_files,
commands::upload_file
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "gui",
"productName": "Nextcloud Upload Tool",
"version": "0.1.0",
"identifier": "me.majinnaibu.nut",
"build": {
@@ -12,9 +12,10 @@
"app": {
"windows": [
{
"title": "gui",
"width": 800,
"height": 600
"label": "main",
"title": "Nextcloud Upload Tool",
"width": 900,
"height": 680
}
],
"security": {

View File

@@ -1,116 +1,356 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
--bg-color: #0f172a;
--surface-color: #1e293b;
--surface-card: #334155;
--border-color: #475569;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--primary: #0284c7;
--primary-hover: #0369a1;
--accent: #38bdf8;
--danger: #ef4444;
--danger-hover: #dc2626;
--success: #10b981;
--radius: 8px;
}
.container {
* {
box-sizing: border-box;
margin: 0;
padding-top: 10vh;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-main);
font-family: var(--font-family);
font-size: 14px;
line-height: 1.5;
user-select: none;
-webkit-font-smoothing: antialiased;
}
.app-container {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
height: 100vh;
max-width: 900px;
margin: 0 auto;
padding: 16px 20px;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
/* Header */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.brand-section {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
background: var(--primary);
color: white;
font-weight: 800;
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
.brand-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.2px;
}
a:hover {
color: #535bf2;
.account-selector {
display: flex;
align-items: center;
gap: 6px;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 4px 8px;
}
h1 {
text-align: center;
.account-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--success);
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
.account-select {
background: transparent;
color: var(--text-main);
border: none;
font-size: 13px;
outline: none;
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
/* Navigation Tabs */
.nav-tabs {
display: flex;
gap: 8px;
margin-top: 14px;
margin-bottom: 14px;
}
input,
button {
.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;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
}
/* Cards */
.card {
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 14px;
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Dropzone */
.file-dropzone {
background: var(--surface-color);
border: 2px dashed var(--border-color);
border-radius: var(--radius);
padding: 24px 16px;
text-align: center;
cursor: pointer;
margin-bottom: 14px;
transition: border-color 0.2s, background-color 0.2s;
}
.file-dropzone:hover {
border-color: var(--accent);
background: rgba(56, 189, 248, 0.05);
}
.dropzone-title {
font-size: 15px;
font-weight: 600;
color: var(--text-main);
}
.dropzone-subtitle {
font-size: 12px;
color: var(--text-muted);
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;
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Form Controls */
.form-group {
margin-bottom: 12px;
}
.form-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 4px;
}
.form-input {
width: 100%;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 12px;
color: var(--text-main);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.form-input:focus {
border-color: var(--accent);
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
font-weight: 600;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
border: none;
transition: background-color 0.15s, opacity 0.15s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--surface-card);
color: var(--text-main);
border: 1px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--border-color);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-hover);
}
/* Results */
.result-card {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 10px;
}
.result-header {
display: flex;
justify-content: space-between;
font-weight: 600;
margin-bottom: 4px;
}
.result-file-name {
color: var(--accent);
}
.result-size {
color: var(--text-muted);
font-size: 12px;
}
.link-row {
display: flex;
gap: 6px;
margin-top: 6px;
}
.link-input {
flex: 1;
background: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 8px;
color: var(--text-main);
font-size: 12px;
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #047857;
color: white;
padding: 10px 18px;
border-radius: var(--radius);
font-weight: 600;
font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
}

View File

@@ -1,50 +1,335 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import "./App.css";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
interface StoredAccount {
id: string;
label?: string;
username: string;
server_url: string;
is_default: boolean;
}
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
setGreetMsg(await invoke("greet", { name }));
}
interface GuiUploadResult {
file_path: string;
file_name: string;
remote_path: string;
bytes_uploaded: number;
share_url?: string;
direct_download_url?: string;
}
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);
// 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 showToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => {
setToastMessage(null);
}, 3000);
};
const loadAccounts = async () => {
try {
const list = await invoke<StoredAccount[]>("list_accounts");
setAccounts(list);
const def = list.find((a) => a.is_default);
if (def) {
setSelectedAccount(def.id);
} else if (list.length > 0) {
setSelectedAccount(list[0].id);
} else {
setSelectedAccount("");
}
} catch (e) {
console.error("Failed to load accounts:", e);
}
};
useEffect(() => {
loadAccounts();
}, []);
const handleSelectFiles = async () => {
try {
const files = await invoke<string[]>("select_files");
if (files && files.length > 0) {
setSelectedFiles((prev) => Array.from(new Set([...prev, ...files])));
}
} catch (e) {
showToast(`Error picking files: ${e}`);
}
};
const handleRemoveSelectedFile = (path: string) => {
setSelectedFiles((prev) => prev.filter((p) => p !== path));
};
const handleUpload = async () => {
if (selectedFiles.length === 0) {
showToast("Please select at least one file to upload.");
return;
}
if (accounts.length === 0) {
showToast("No account configured. Please configure an account in your credentials store.");
return;
}
setIsUploading(true);
const results: GuiUploadResult[] = [];
for (const filePath of selectedFiles) {
try {
const res = await invoke<GuiUploadResult>("upload_file", {
filePath,
remoteDir,
createShare,
sharePassword: sharePassword.trim() ? sharePassword.trim() : null,
accountId: selectedAccount ? selectedAccount : null,
});
results.push(res);
} catch (e) {
showToast(`Failed to upload ${filePath}: ${e}`);
}
}
setUploadResults(results);
setSelectedFiles([]);
setIsUploading(false);
showToast(`Uploaded ${results.length} file(s) successfully!`);
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
showToast(`Copied ${label} to clipboard!`);
};
const handleOpenBrowser = (url: string) => {
openUrl(url).catch((err) => {
console.error("Failed to open URL:", err);
});
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
return (
<main className="container">
<h1>Welcome to Tauri + React</h1>
<div className="app-container">
{/* Header */}
<header className="app-header">
<div className="brand-section">
<div className="brand-logo">N</div>
<span className="brand-title">Nextcloud Upload Tool</span>
</div>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<div className="header-actions">
{accounts.length > 0 ? (
<div className="account-selector">
<div className="account-dot" />
<select
className="account-select"
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
>
{accounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.label ? `${acc.label} (${acc.id})` : acc.id}
</option>
))}
</select>
</div>
) : (
<span style={{ fontSize: 13, color: "var(--danger)" }}>
No Account Connected
</span>
)}
</div>
</header>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</main>
{/* 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>
{/* 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>
)}
<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>
{/* 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>
</div>
{uploadResults.map((r, i) => (
<div key={i} 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>
</div>
<div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 8 }}>
Destination: {r.remote_path}
</div>
{r.share_url && (
<div className="link-row">
<input className="link-input" readOnly value={r.share_url} />
<button
className="btn btn-secondary btn-sm"
onClick={() => handleCopy(r.share_url!, "Share Link")}
>
Copy
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenBrowser(r.share_url!)}
>
Open
</button>
</div>
)}
{r.direct_download_url && (
<div className="link-row">
<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")}
>
Copy Direct
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</main>
{/* Floating Toast */}
{toastMessage && <div className="toast">{toastMessage}</div>}
</div>
);
}