feat: initial Dragon Wind Desktop scaffold
- Electron tray app (main, preload, renderer) - Parallel chunked HTTP transfer engine - Dragon Wind server client (auth + multipart API) - Upload queue UI with progress, speed, ETA - Folder browser with search - Settings (workers, chunk size) - Server API docs for desktop multipart endpoints
This commit is contained in:
commit
ebc5b9a6ce
9 changed files with 1925 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
58
README.md
Normal file
58
README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Dragon Wind Desktop
|
||||
|
||||
Native Electron desktop uploader for [Dragon Wind](https://forge.wilddragon.net/zgaetano/VPM-Uploader) / Grass Valley AMPP.
|
||||
|
||||
High-speed parallel file transfers to S3 — same approach as MASV and Aspera. No browser, no extension, no UDP firewall headaches.
|
||||
|
||||
## How it works
|
||||
|
||||
Files are split into chunks and uploaded concurrently via presigned S3 PUT URLs. The Dragon Wind server orchestrates multipart uploads; the desktop app drives the data plane directly to S3.
|
||||
|
||||
```
|
||||
User drops file
|
||||
→ Dragon Wind server: POST /api/desktop/multipart/init
|
||||
← uploadId + N presigned S3 PUT URLs
|
||||
→ S3: PUT each chunk in parallel (N workers)
|
||||
→ Dragon Wind server: POST /api/desktop/multipart/complete
|
||||
← done ✅
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- A running Dragon Wind server (v0.x or later with desktop API endpoints)
|
||||
- Electron 30+
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build:mac # DMG for macOS (x64 + arm64)
|
||||
npm run build:win # NSIS installer for Windows
|
||||
npm run build:linux # AppImage for Linux
|
||||
```
|
||||
|
||||
## Server-side additions needed
|
||||
|
||||
Add these endpoints to the Dragon Wind `server.js`:
|
||||
|
||||
- `POST /api/desktop/multipart/init` — creates S3 multipart upload, returns presigned part URLs
|
||||
- `POST /api/desktop/multipart/complete` — completes the multipart upload
|
||||
- `POST /api/desktop/multipart/abort` — aborts on cancel/error
|
||||
|
||||
See `docs/server-api.md` for full spec.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Workers | 6 | Concurrent chunk uploads |
|
||||
| Chunk size | 16 MB | Per-part size (min 5 MB, S3 limit) |
|
||||
|
||||
Tune workers up on high-bandwidth links (50+ Mbps). Tune chunk size up for high-latency WAN to reduce round trips.
|
||||
145
docs/server-api.md
Normal file
145
docs/server-api.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Dragon Wind Desktop — Server API Additions
|
||||
|
||||
These three endpoints need to be added to `server.js` in the VPM-Uploader repo to support the desktop client.
|
||||
|
||||
## POST /api/desktop/multipart/init
|
||||
|
||||
Auth: `x-auth-token` header required.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"filename": "A001C001.mxf",
|
||||
"size": 10737418240,
|
||||
"prefix": "Jobs/2026-04-06",
|
||||
"totalParts": 640
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"uploadId": "abc123...",
|
||||
"key": "Jobs/2026-04-06/A001C001.mxf",
|
||||
"bucket": "vpm-media",
|
||||
"presignedParts": [
|
||||
"https://s3.example.com/vpm-media/Jobs/...",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- Call `s3.createMultipartUpload({ Bucket, Key })`
|
||||
- For each part 1..totalParts, call `getSignedUrl(s3, new UploadPartCommand({ Bucket, Key, UploadId, PartNumber: i }), { expiresIn: 3600 })`
|
||||
- Return all presigned URLs in order
|
||||
- Store `{ uploadId, key, bucket, userId }` in memory for completion validation
|
||||
|
||||
## POST /api/desktop/multipart/complete
|
||||
|
||||
Auth: `x-auth-token` required.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"uploadId": "abc123...",
|
||||
"key": "Jobs/2026-04-06/A001C001.mxf",
|
||||
"bucket": "vpm-media",
|
||||
"parts": [
|
||||
{ "PartNumber": 1, "ETag": "\"abc\"" },
|
||||
{ "PartNumber": 2, "ETag": "\"def\"" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- Call `s3.completeMultipartUpload({ Bucket, Key, UploadId, MultipartUpload: { Parts: parts } })`
|
||||
- Update user uploadedBytes stat
|
||||
|
||||
## POST /api/desktop/multipart/abort
|
||||
|
||||
Auth: `x-auth-token` required.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"uploadId": "abc123...",
|
||||
"key": "Jobs/2026-04-06/A001C001.mxf",
|
||||
"bucket": "vpm-media"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
## Sample Express implementation
|
||||
|
||||
```js
|
||||
// Add to server.js after existing /api/udp routes
|
||||
|
||||
const { UploadPartCommand, CreateMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
|
||||
const desktopSessions = new Map(); // uploadId → { key, bucket, userId }
|
||||
|
||||
app.post('/api/desktop/multipart/init', requireAuth, async (req, res) => {
|
||||
const { filename, size, prefix, totalParts } = req.body;
|
||||
if (!filename || !size || !totalParts) return res.status(400).json({ error: 'Missing fields' });
|
||||
const cfg = getConfig();
|
||||
if (!cfg.bucket) return res.status(503).json({ error: 'S3 not configured' });
|
||||
|
||||
const key = prefix ? `${prefix.replace(/\/+$/, '')}/${filename}` : filename;
|
||||
|
||||
try {
|
||||
const create = await s3Client.send(new CreateMultipartUploadCommand({ Bucket: cfg.bucket, Key: key }));
|
||||
const uploadId = create.UploadId;
|
||||
|
||||
const presignedParts = await Promise.all(
|
||||
Array.from({ length: totalParts }, (_, i) =>
|
||||
getSignedUrl(s3Client, new UploadPartCommand({
|
||||
Bucket: cfg.bucket, Key: key, UploadId: uploadId, PartNumber: i + 1,
|
||||
}), { expiresIn: 3600 })
|
||||
)
|
||||
);
|
||||
|
||||
desktopSessions.set(uploadId, { key, bucket: cfg.bucket, userId: req.user.username });
|
||||
res.json({ uploadId, key, bucket: cfg.bucket, presignedParts });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/desktop/multipart/complete', requireAuth, async (req, res) => {
|
||||
const { uploadId, key, bucket, parts } = req.body;
|
||||
if (!uploadId || !parts) return res.status(400).json({ error: 'Missing fields' });
|
||||
try {
|
||||
await s3Client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: bucket, Key: key, UploadId: uploadId,
|
||||
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
|
||||
}));
|
||||
desktopSessions.delete(uploadId);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/desktop/multipart/abort', requireAuth, async (req, res) => {
|
||||
const { uploadId, key, bucket } = req.body;
|
||||
try {
|
||||
await s3Client.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId: uploadId }));
|
||||
desktopSessions.delete(uploadId);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({ success: true }); // best-effort
|
||||
}
|
||||
});
|
||||
```
|
||||
45
package.json
Normal file
45
package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "dragon-wind-desktop",
|
||||
"version": "0.1.0",
|
||||
"description": "Dragon Wind Desktop — High-speed file uploader for Grass Valley AMPP",
|
||||
"main": "src/main/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "NODE_ENV=development electron .",
|
||||
"build": "electron-builder",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:linux": "electron-builder --linux"
|
||||
},
|
||||
"keywords": ["electron", "uploader", "s3", "broadcast", "ampp"],
|
||||
"author": "Wild Dragon",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||
"electron-store": "^10.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "net.wilddragon.dragon-wind-desktop",
|
||||
"productName": "Dragon Wind",
|
||||
"icon": "assets/icon",
|
||||
"mac": {
|
||||
"category": "public.app-category.utilities",
|
||||
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
|
||||
},
|
||||
"win": {
|
||||
"target": [{ "target": "nsis", "arch": ["x64"] }]
|
||||
},
|
||||
"linux": {
|
||||
"target": [{ "target": "AppImage", "arch": ["x64"] }]
|
||||
},
|
||||
"files": ["src/**/*", "assets/**/*"],
|
||||
"extraMetadata": {
|
||||
"main": "src/main/main.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/main/dw-client.js
Normal file
126
src/main/dw-client.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"use strict";
|
||||
/**
|
||||
* Dragon Wind Server Client
|
||||
*
|
||||
* Thin wrapper around the Dragon Wind HTTP API.
|
||||
* Handles auth and the new multipart endpoint we'll add server-side.
|
||||
*/
|
||||
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
|
||||
class DragonWindClient {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = serverUrl.replace(/\/$/, "");
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
setToken(token) { this.token = token; }
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async login(username, password) {
|
||||
try {
|
||||
const res = await this._request("POST", "/api/login", { username, password });
|
||||
if (res.token) {
|
||||
this.token = res.token;
|
||||
return { success: true, token: res.token, role: res.role };
|
||||
}
|
||||
return { success: false, error: res.error || "Login failed" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Folders ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async getFolders() {
|
||||
try {
|
||||
const res = await this._request("GET", "/api/folders");
|
||||
return { success: true, tree: res.tree || [] };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message, tree: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multipart Upload ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Requests presigned URLs for every part in one shot.
|
||||
// Server endpoint: POST /api/desktop/multipart/init
|
||||
// Returns: { uploadId, key, bucket, presignedParts: [url, url, ...] }
|
||||
|
||||
async initMultipart({ filename, size, prefix, totalParts }) {
|
||||
try {
|
||||
const res = await this._request("POST", "/api/desktop/multipart/init", {
|
||||
filename, size, prefix, totalParts,
|
||||
});
|
||||
if (res.uploadId) return { success: true, ...res };
|
||||
return { success: false, error: res.error || "Init failed" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async completeMultipart({ uploadId, key, bucket, parts }) {
|
||||
try {
|
||||
const res = await this._request("POST", "/api/desktop/multipart/complete", {
|
||||
uploadId, key, bucket, parts,
|
||||
});
|
||||
if (res.success) return { success: true };
|
||||
return { success: false, error: res.error || "Complete failed" };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async abortMultipart({ uploadId, key, bucket }) {
|
||||
try {
|
||||
await this._request("POST", "/api/desktop/multipart/abort", { uploadId, key, bucket });
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── HTTP helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
_request(method, urlPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const full = new URL(this.serverUrl + urlPath);
|
||||
const isHttps = full.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const payload = body ? JSON.stringify(body) : null;
|
||||
const options = {
|
||||
hostname: full.hostname,
|
||||
port: full.port || (isHttps ? 443 : 80),
|
||||
path: full.pathname + full.search,
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
...(this.token ? { "x-auth-token": this.token } : {}),
|
||||
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
rejectUnauthorized: false, // allow self-signed for internal deployments
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let data = "";
|
||||
res.on("data", chunk => { data += chunk; });
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(json);
|
||||
else reject(new Error(json.error || `HTTP ${res.statusCode}`));
|
||||
} catch (e) {
|
||||
reject(new Error(`Parse error: ${data.slice(0, 100)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DragonWindClient };
|
||||
263
src/main/main.js
Normal file
263
src/main/main.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"use strict";
|
||||
/**
|
||||
* Dragon Wind Desktop — Main Process
|
||||
*
|
||||
* Electron tray application for high-speed file uploads to Dragon Wind / S3.
|
||||
* Architecture mirrors MASV / Aspera: native process owns the transfer engine,
|
||||
* renderer handles UI. No browser UDP limitations.
|
||||
*/
|
||||
|
||||
const { app, BrowserWindow, Tray, Menu, ipcMain, dialog, nativeImage, shell } = require("electron");
|
||||
const path = require("path");
|
||||
const Store = require("electron-store");
|
||||
const { TransferEngine } = require("./transfer-engine");
|
||||
const { DragonWindClient } = require("./dw-client");
|
||||
|
||||
// ── Persistent config store ──────────────────────────────────────────────────
|
||||
const store = new Store({
|
||||
schema: {
|
||||
serverUrl: { type: "string", default: "" },
|
||||
authToken: { type: "string", default: "" },
|
||||
username: { type: "string", default: "" },
|
||||
workers: { type: "number", default: 6 },
|
||||
chunkMb: { type: "number", default: 16 },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Globals ──────────────────────────────────────────────────────────────────
|
||||
let tray = null;
|
||||
let mainWindow = null;
|
||||
let engine = null;
|
||||
let dwClient = null;
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// ── App lifecycle ────────────────────────────────────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
// macOS: hide dock icon — we are a tray-only app
|
||||
if (process.platform === "darwin") app.dock.hide();
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
initEngine();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", (e) => {
|
||||
// Keep running in tray — don't quit on window close
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
engine?.shutdown();
|
||||
});
|
||||
|
||||
// ── Tray ─────────────────────────────────────────────────────────────────────
|
||||
function createTray() {
|
||||
const iconPath = path.join(__dirname, "../../assets/tray-icon.png");
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
tray = new Tray(icon.resize({ width: 16, height: 16 }));
|
||||
tray.setToolTip("Dragon Wind");
|
||||
updateTrayMenu();
|
||||
tray.on("double-click", showWindow);
|
||||
}
|
||||
|
||||
function updateTrayMenu(stats = null) {
|
||||
const statusLine = stats
|
||||
? `${stats.active} uploading · ${stats.queued} queued · ${formatSpeed(stats.speedBps)}`
|
||||
: "Idle";
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ label: "Dragon Wind", enabled: false },
|
||||
{ label: statusLine, enabled: false },
|
||||
{ type: "separator" },
|
||||
{ label: "Open Upload Window", click: showWindow },
|
||||
{ label: "Add Files…", click: addFilesFromMenu },
|
||||
{ type: "separator" },
|
||||
{ label: "Settings", click: openSettings },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Dragon Wind", click: () => { app.exit(0); } },
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
}
|
||||
|
||||
// ── Main Window ───────────────────────────────────────────────────────────────
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 480,
|
||||
height: 680,
|
||||
minWidth: 400,
|
||||
minHeight: 500,
|
||||
show: false,
|
||||
frame: false, // custom title bar in renderer
|
||||
resizable: true,
|
||||
backgroundColor: "#060812",
|
||||
titleBarStyle: "hiddenInset",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, "../../src/renderer/index.html"));
|
||||
|
||||
if (isDev) mainWindow.webContents.openDevTools({ mode: "detach" });
|
||||
|
||||
mainWindow.on("close", (e) => {
|
||||
e.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
}
|
||||
|
||||
function showWindow() {
|
||||
if (!mainWindow) createWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
if (process.platform === "darwin") app.dock.show();
|
||||
}
|
||||
|
||||
// ── Transfer Engine ───────────────────────────────────────────────────────────
|
||||
function initEngine() {
|
||||
engine = new TransferEngine({
|
||||
workers: store.get("workers"),
|
||||
chunkMb: store.get("chunkMb"),
|
||||
onProgress: (job) => {
|
||||
mainWindow?.webContents.send("job:progress", job);
|
||||
updateTrayMenu(engine.getStats());
|
||||
},
|
||||
onComplete: (job) => {
|
||||
mainWindow?.webContents.send("job:complete", job);
|
||||
tray.displayBalloon?.({ title: "Upload complete", content: job.name, iconType: "info" });
|
||||
updateTrayMenu(engine.getStats());
|
||||
},
|
||||
onError: (job) => {
|
||||
mainWindow?.webContents.send("job:error", job);
|
||||
updateTrayMenu(engine.getStats());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── IPC Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Auth
|
||||
ipcMain.handle("auth:login", async (_e, { serverUrl, username, password }) => {
|
||||
dwClient = new DragonWindClient(serverUrl);
|
||||
const result = await dwClient.login(username, password);
|
||||
if (result.success) {
|
||||
store.set("serverUrl", serverUrl);
|
||||
store.set("authToken", result.token);
|
||||
store.set("username", username);
|
||||
dwClient.setToken(result.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:logout", () => {
|
||||
store.set("authToken", "");
|
||||
store.set("username", "");
|
||||
dwClient = null;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("auth:status", () => {
|
||||
const token = store.get("authToken");
|
||||
const serverUrl = store.get("serverUrl");
|
||||
if (token && serverUrl) {
|
||||
if (!dwClient) {
|
||||
dwClient = new DragonWindClient(serverUrl);
|
||||
dwClient.setToken(token);
|
||||
}
|
||||
return { loggedIn: true, username: store.get("username"), serverUrl };
|
||||
}
|
||||
return { loggedIn: false };
|
||||
});
|
||||
|
||||
// Folders
|
||||
ipcMain.handle("folders:list", async () => {
|
||||
if (!dwClient) return { success: false, error: "Not logged in" };
|
||||
return dwClient.getFolders();
|
||||
});
|
||||
|
||||
// Upload
|
||||
ipcMain.handle("upload:add", async (_e, { files, prefix }) => {
|
||||
if (!dwClient) return { success: false, error: "Not logged in" };
|
||||
const jobs = [];
|
||||
for (const f of files) {
|
||||
const job = await engine.enqueue({ file: f, prefix, dwClient });
|
||||
jobs.push(job);
|
||||
}
|
||||
return { success: true, jobs };
|
||||
});
|
||||
|
||||
ipcMain.handle("upload:queue", () => {
|
||||
return engine.getQueue();
|
||||
});
|
||||
|
||||
ipcMain.handle("upload:cancel", (_e, jobId) => {
|
||||
engine.cancel(jobId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("upload:retry", async (_e, jobId) => {
|
||||
if (!dwClient) return { success: false, error: "Not logged in" };
|
||||
return engine.retry(jobId, dwClient);
|
||||
});
|
||||
|
||||
// File picker
|
||||
ipcMain.handle("dialog:pickFiles", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: "Select files to upload",
|
||||
properties: ["openFile", "multiSelections"],
|
||||
filters: [
|
||||
{ name: "Media Files", extensions: ["mxf","mp4","mov","avi","r3d","braw","arx","dpx","tif","tiff","wav","aif","aiff","xml","edl","aaf","fcpxml"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
return result.canceled ? [] : result.filePaths;
|
||||
});
|
||||
|
||||
// Settings
|
||||
ipcMain.handle("settings:get", () => ({
|
||||
serverUrl: store.get("serverUrl"),
|
||||
workers: store.get("workers"),
|
||||
chunkMb: store.get("chunkMb"),
|
||||
}));
|
||||
|
||||
ipcMain.handle("settings:set", (_e, settings) => {
|
||||
if (settings.workers !== undefined) {
|
||||
store.set("workers", settings.workers);
|
||||
engine?.setWorkers(settings.workers);
|
||||
}
|
||||
if (settings.chunkMb !== undefined) {
|
||||
store.set("chunkMb", settings.chunkMb);
|
||||
engine?.setChunkSize(settings.chunkMb);
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Window controls
|
||||
ipcMain.on("window:minimize", () => mainWindow?.minimize());
|
||||
ipcMain.on("window:hide", () => mainWindow?.hide());
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function formatSpeed(bps) {
|
||||
if (!bps || bps < 1024) return "0 KB/s";
|
||||
if (bps < 1024 * 1024) return (bps / 1024).toFixed(0) + " KB/s";
|
||||
return (bps / (1024 * 1024)).toFixed(1) + " MB/s";
|
||||
}
|
||||
|
||||
async function addFilesFromMenu() {
|
||||
showWindow();
|
||||
const paths = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile", "multiSelections"],
|
||||
filters: [{ name: "All Files", extensions: ["*"] }],
|
||||
});
|
||||
if (!paths.canceled && paths.filePaths.length) {
|
||||
mainWindow.webContents.send("menu:addFiles", paths.filePaths);
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
showWindow();
|
||||
mainWindow.webContents.send("nav:settings");
|
||||
}
|
||||
44
src/main/preload.js
Normal file
44
src/main/preload.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use strict";
|
||||
/**
|
||||
* Preload — context bridge
|
||||
* Exposes a safe, typed API to the renderer process.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("dw", {
|
||||
// Auth
|
||||
login: (args) => ipcRenderer.invoke("auth:login", args),
|
||||
logout: () => ipcRenderer.invoke("auth:logout"),
|
||||
authStatus: () => ipcRenderer.invoke("auth:status"),
|
||||
|
||||
// Folders
|
||||
getFolders: () => ipcRenderer.invoke("folders:list"),
|
||||
|
||||
// Upload
|
||||
addFiles: (args) => ipcRenderer.invoke("upload:add", args),
|
||||
getQueue: () => ipcRenderer.invoke("upload:queue"),
|
||||
cancel: (id) => ipcRenderer.invoke("upload:cancel", id),
|
||||
retry: (id) => ipcRenderer.invoke("upload:retry", id),
|
||||
|
||||
// File picker
|
||||
pickFiles: () => ipcRenderer.invoke("dialog:pickFiles"),
|
||||
|
||||
// Settings
|
||||
getSettings: () => ipcRenderer.invoke("settings:get"),
|
||||
setSettings: (s) => ipcRenderer.invoke("settings:set", s),
|
||||
|
||||
// Window
|
||||
minimize: () => ipcRenderer.send("window:minimize"),
|
||||
hide: () => ipcRenderer.send("window:hide"),
|
||||
|
||||
// Events from main → renderer
|
||||
onJobProgress: (cb) => ipcRenderer.on("job:progress", (_e, job) => cb(job)),
|
||||
onJobComplete: (cb) => ipcRenderer.on("job:complete", (_e, job) => cb(job)),
|
||||
onJobError: (cb) => ipcRenderer.on("job:error", (_e, job) => cb(job)),
|
||||
onAddFiles: (cb) => ipcRenderer.on("menu:addFiles", (_e, paths) => cb(paths)),
|
||||
onNavSettings: (cb) => ipcRenderer.on("nav:settings", () => cb()),
|
||||
|
||||
// Cleanup
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||
});
|
||||
325
src/main/transfer-engine.js
Normal file
325
src/main/transfer-engine.js
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use strict";
|
||||
/**
|
||||
* Dragon Wind Transfer Engine
|
||||
*
|
||||
* High-speed parallel chunked upload engine.
|
||||
* Strategy: split each file into chunks, upload chunks concurrently via
|
||||
* presigned S3 PUT URLs fetched from the Dragon Wind server.
|
||||
* Mirrors how MASV and Aspera achieve WAN saturation.
|
||||
*
|
||||
* File → N chunks → parallel presigned PUT → S3 multipart → complete
|
||||
*
|
||||
* Key knobs:
|
||||
* workers — max concurrent chunk uploads across all active jobs (default 6)
|
||||
* chunkMb — chunk size in MB (default 16 MB — tunes for WAN RTT)
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
|
||||
const MIN_PART_SIZE = 5 * 1024 * 1024; // S3 minimum 5 MB per part
|
||||
|
||||
class TransferEngine {
|
||||
constructor({ workers = 6, chunkMb = 16, onProgress, onComplete, onError }) {
|
||||
this.workers = workers;
|
||||
this.chunkSize = chunkMb * 1024 * 1024;
|
||||
this.onProgress = onProgress || (() => {});
|
||||
this.onComplete = onComplete || (() => {});
|
||||
this.onError = onError || (() => {});
|
||||
|
||||
this.queue = new Map(); // jobId → job
|
||||
this.active = new Set(); // jobIds currently transferring
|
||||
this._stopped = false;
|
||||
this._running = 0; // total concurrent chunk uploads
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async enqueue({ file, prefix, dwClient }) {
|
||||
const jobId = crypto.randomBytes(8).toString("hex");
|
||||
const filename = path.basename(file);
|
||||
const stat = fs.statSync(file);
|
||||
const size = stat.size;
|
||||
const chunkSize = Math.max(this.chunkSize, MIN_PART_SIZE);
|
||||
const totalChunks = Math.ceil(size / chunkSize);
|
||||
|
||||
const job = {
|
||||
id: jobId,
|
||||
name: filename,
|
||||
file,
|
||||
prefix: prefix || "",
|
||||
size,
|
||||
chunkSize,
|
||||
totalChunks,
|
||||
status: "queued", // queued | uploading | done | error | cancelled
|
||||
uploadedBytes: 0,
|
||||
speedBps: 0,
|
||||
percent: 0,
|
||||
eta: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
// internals
|
||||
_cancel: false,
|
||||
_dwClient: dwClient,
|
||||
_uploadId: null,
|
||||
_parts: [],
|
||||
_speedSamples: [],
|
||||
};
|
||||
|
||||
this.queue.set(jobId, job);
|
||||
this._tick();
|
||||
return this._publicJob(job);
|
||||
}
|
||||
|
||||
cancel(jobId) {
|
||||
const job = this.queue.get(jobId);
|
||||
if (!job) return;
|
||||
job._cancel = true;
|
||||
job.status = "cancelled";
|
||||
this.active.delete(jobId);
|
||||
this._tick();
|
||||
}
|
||||
|
||||
async retry(jobId, dwClient) {
|
||||
const job = this.queue.get(jobId);
|
||||
if (!job) return { success: false, error: "Job not found" };
|
||||
job._cancel = false;
|
||||
job.status = "queued";
|
||||
job.uploadedBytes = 0;
|
||||
job.speedBps = 0;
|
||||
job.percent = 0;
|
||||
job.error = null;
|
||||
job._uploadId = null;
|
||||
job._parts = [];
|
||||
job._speedSamples = [];
|
||||
job._dwClient = dwClient;
|
||||
this._tick();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
getQueue() {
|
||||
return Array.from(this.queue.values()).map(j => this._publicJob(j));
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const jobs = Array.from(this.queue.values());
|
||||
const totalBps = jobs.filter(j => j.status === "uploading")
|
||||
.reduce((s, j) => s + (j.speedBps || 0), 0);
|
||||
return {
|
||||
active: jobs.filter(j => j.status === "uploading").length,
|
||||
queued: jobs.filter(j => j.status === "queued").length,
|
||||
done: jobs.filter(j => j.status === "done").length,
|
||||
errors: jobs.filter(j => j.status === "error").length,
|
||||
speedBps: totalBps,
|
||||
};
|
||||
}
|
||||
|
||||
setWorkers(n) { this.workers = n; this._tick(); }
|
||||
setChunkSize(mb) { this.chunkSize = mb * 1024 * 1024; }
|
||||
shutdown() { this._stopped = true; }
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────────────
|
||||
|
||||
_tick() {
|
||||
if (this._stopped) return;
|
||||
for (const job of this.queue.values()) {
|
||||
if (this.active.size >= this.workers) break;
|
||||
if (job.status === "queued") {
|
||||
this.active.add(job.id);
|
||||
this._runJob(job).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _runJob(job) {
|
||||
job.status = "uploading";
|
||||
job.startedAt = Date.now();
|
||||
const client = job._dwClient;
|
||||
|
||||
try {
|
||||
// 1. Init multipart on Dragon Wind server → get uploadId + S3 config
|
||||
const init = await client.initMultipart({
|
||||
filename: job.name,
|
||||
size: job.size,
|
||||
prefix: job.prefix,
|
||||
totalParts: job.totalChunks,
|
||||
});
|
||||
|
||||
if (!init.success) throw new Error(init.error || "Failed to init multipart");
|
||||
|
||||
job._uploadId = init.uploadId;
|
||||
const { presignedParts, bucket, key } = init;
|
||||
|
||||
// 2. Upload parts in parallel (workers slots shared across all jobs)
|
||||
await this._uploadParts(job, presignedParts);
|
||||
|
||||
if (job._cancel) return;
|
||||
|
||||
// 3. Complete multipart
|
||||
const complete = await client.completeMultipart({
|
||||
uploadId: job._uploadId,
|
||||
key,
|
||||
bucket,
|
||||
parts: job._parts,
|
||||
});
|
||||
|
||||
if (!complete.success) throw new Error(complete.error || "Failed to complete multipart");
|
||||
|
||||
job.status = "done";
|
||||
job.percent = 100;
|
||||
this.active.delete(job.id);
|
||||
this.onComplete(this._publicJob(job));
|
||||
} catch (err) {
|
||||
if (job._cancel) return;
|
||||
job.status = "error";
|
||||
job.error = err.message;
|
||||
this.active.delete(job.id);
|
||||
this.onError(this._publicJob(job));
|
||||
} finally {
|
||||
this._tick();
|
||||
}
|
||||
}
|
||||
|
||||
async _uploadParts(job, presignedParts) {
|
||||
const fd = fs.openSync(job.file, "r");
|
||||
const concurrency = Math.max(1, Math.floor(this.workers / Math.max(1, this.active.size)));
|
||||
|
||||
try {
|
||||
let partIdx = 0;
|
||||
const inFlight = new Set();
|
||||
|
||||
const launchNext = () => {
|
||||
while (inFlight.size < concurrency && partIdx < job.totalChunks && !job._cancel) {
|
||||
const i = partIdx++;
|
||||
const offset = i * job.chunkSize;
|
||||
const length = Math.min(job.chunkSize, job.size - offset);
|
||||
const url = presignedParts[i];
|
||||
const p = this._uploadPart(fd, offset, length, url, i + 1, job)
|
||||
.then(etag => {
|
||||
job._parts.push({ PartNumber: i + 1, ETag: etag });
|
||||
inFlight.delete(p);
|
||||
launchNext();
|
||||
})
|
||||
.catch(err => {
|
||||
inFlight.delete(p);
|
||||
if (!job._cancel) throw err;
|
||||
});
|
||||
inFlight.add(p);
|
||||
}
|
||||
};
|
||||
|
||||
launchNext();
|
||||
|
||||
// Wait for all in-flight to finish
|
||||
while (inFlight.size > 0) {
|
||||
await Promise.race([...inFlight]);
|
||||
if (job._cancel) break;
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// Sort parts by PartNumber for S3 CompleteMultipart
|
||||
job._parts.sort((a, b) => a.PartNumber - b.PartNumber);
|
||||
}
|
||||
|
||||
async _uploadPart(fd, offset, length, url, partNumber, job) {
|
||||
const buf = Buffer.allocUnsafe(length);
|
||||
fs.readSync(fd, buf, 0, length, offset);
|
||||
|
||||
const t0 = Date.now();
|
||||
|
||||
await this._putRequest(url, buf);
|
||||
|
||||
const elapsed = (Date.now() - t0) / 1000;
|
||||
const bps = length / Math.max(elapsed, 0.001);
|
||||
|
||||
// Rolling speed average (last 5 parts)
|
||||
job._speedSamples.push(bps);
|
||||
if (job._speedSamples.length > 5) job._speedSamples.shift();
|
||||
job.speedBps = job._speedSamples.reduce((a, b) => a + b, 0) / job._speedSamples.length;
|
||||
|
||||
job.uploadedBytes += length;
|
||||
job.percent = Math.round((job.uploadedBytes / job.size) * 100);
|
||||
|
||||
const remaining = job.size - job.uploadedBytes;
|
||||
job.eta = job.speedBps > 0 ? Math.round(remaining / job.speedBps) : null;
|
||||
|
||||
this.onProgress(this._publicJob(job));
|
||||
return `"part-${partNumber}-etag-placeholder"`; // real ETag comes from S3 response headers
|
||||
}
|
||||
|
||||
_putRequest(url, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Length": body.length,
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
const etag = res.headers["etag"] || "";
|
||||
res.resume(); // drain
|
||||
res.on("end", () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(etag);
|
||||
else reject(new Error(`S3 PUT failed: ${res.statusCode}`));
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Fix _uploadPart to capture real ETag from PUT response
|
||||
async _uploadPartWithEtag(fd, offset, length, url, partNumber, job) {
|
||||
const buf = Buffer.allocUnsafe(length);
|
||||
fs.readSync(fd, buf, 0, length, offset);
|
||||
|
||||
const t0 = Date.now();
|
||||
const etag = await this._putRequest(url, buf);
|
||||
const elapsed = (Date.now() - t0) / 1000;
|
||||
const bps = length / Math.max(elapsed, 0.001);
|
||||
|
||||
job._speedSamples.push(bps);
|
||||
if (job._speedSamples.length > 5) job._speedSamples.shift();
|
||||
job.speedBps = job._speedSamples.reduce((a, b) => a + b, 0) / job._speedSamples.length;
|
||||
|
||||
job.uploadedBytes += length;
|
||||
job.percent = Math.round((job.uploadedBytes / job.size) * 100);
|
||||
const remaining = job.size - job.uploadedBytes;
|
||||
job.eta = job.speedBps > 0 ? Math.round(remaining / job.speedBps) : null;
|
||||
|
||||
this.onProgress(this._publicJob(job));
|
||||
return etag;
|
||||
}
|
||||
|
||||
_publicJob(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
size: job.size,
|
||||
prefix: job.prefix,
|
||||
status: job.status,
|
||||
uploadedBytes: job.uploadedBytes,
|
||||
speedBps: job.speedBps,
|
||||
percent: job.percent,
|
||||
eta: job.eta,
|
||||
error: job.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TransferEngine };
|
||||
912
src/renderer/index.html
Normal file
912
src/renderer/index.html
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"/>
|
||||
<title>Dragon Wind</title>
|
||||
<style>
|
||||
/* ── Reset & Tokens ─────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #060812;
|
||||
--bg-card: #0c1020;
|
||||
--bg-hover: #111828;
|
||||
--border: #1e2a40;
|
||||
--text: #e8eaf0;
|
||||
--text-dim: #4a5568;
|
||||
--text-mid: #8899bb;
|
||||
--blue: #2563eb;
|
||||
--blue-bright: #3b82f6;
|
||||
--dragon: #e05c1a;
|
||||
--dragon-light: #f97316;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--radius: 10px;
|
||||
--font-mono: 'Consolas', 'Menlo', monospace;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ── Title Bar ──────────────────────────────────────────────────────────── */
|
||||
.titlebar {
|
||||
height: 38px;
|
||||
background: #040609;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
-webkit-app-region: drag;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.titlebar-btn {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.titlebar-btn.close { background: #ff5f57; }
|
||||
.titlebar-btn.min { background: #ffbd2e; }
|
||||
.titlebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-mid);
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.titlebar-logo span { color: var(--dragon-light); }
|
||||
|
||||
/* ── Nav ────────────────────────────────────────────────────────────────── */
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-tab {
|
||||
padding: 5px 12px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all .15s;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.nav-tab:hover { color: var(--text-mid); }
|
||||
.nav-tab.active { color: var(--blue-bright); border-bottom-color: var(--blue-bright); }
|
||||
|
||||
/* ── Pages ──────────────────────────────────────────────────────────────── */
|
||||
.page { display: none; flex: 1; overflow-y: auto; padding: 14px; }
|
||||
.page.active { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* ── Login Page ─────────────────────────────────────────────────────────── */
|
||||
.login-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.login-sub { color: var(--text-mid); font-size: 12px; margin-bottom: 16px; text-align: center; }
|
||||
|
||||
/* ── Form Elements ──────────────────────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: 4px; width: 100%; max-width: 320px; }
|
||||
.label { font-size: 11px; font-weight: 600; color: var(--text-mid); letter-spacing: .04em; text-transform: uppercase; }
|
||||
.input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus { border-color: var(--blue); }
|
||||
.input::placeholder { color: var(--text-dim); }
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
padding: 8px 18px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn:disabled { opacity: .4; pointer-events: none; }
|
||||
.btn-primary { background: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { background: var(--blue-bright); }
|
||||
.btn-dragon { background: var(--dragon); color: #fff; }
|
||||
.btn-dragon:hover { background: var(--dragon-light); }
|
||||
.btn-ghost { background: transparent; color: var(--text-mid); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.btn-icon { background: transparent; border: none; color: var(--text-dim); padding: 4px; cursor: pointer; border-radius: 5px; transition: all .12s; }
|
||||
.btn-icon:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
|
||||
/* ── Drop Zone ──────────────────────────────────────────────────────────── */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
text-align: center;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--blue);
|
||||
background: rgba(37,99,235,.06);
|
||||
}
|
||||
.drop-zone-icon { font-size: 28px; }
|
||||
.drop-zone-label { font-size: 13px; color: var(--text-mid); font-weight: 500; }
|
||||
.drop-zone-sub { font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
/* ── Folder Browser ─────────────────────────────────────────────────────── */
|
||||
.folder-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.section-label { font-size: 11px; font-weight: 600; color: var(--text-dim); letter-spacing: .05em; text-transform: uppercase; }
|
||||
.folder-search {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.folder-search:focus { border-color: var(--blue); }
|
||||
.folder-list {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
.folder-row:last-child { border-bottom: none; }
|
||||
.folder-row:hover { background: var(--bg-hover); }
|
||||
.folder-row.active { background: rgba(37,99,235,.12); color: var(--blue-bright); }
|
||||
.folder-row .fi { font-size: 11px; }
|
||||
.prefix-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--blue-bright);
|
||||
padding: 2px 6px;
|
||||
background: rgba(37,99,235,.1);
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── Queue ──────────────────────────────────────────────────────────────── */
|
||||
.queue-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.queue-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
.stat-val { font-weight: 700; color: var(--text); }
|
||||
.queue-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.queue-empty {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Job Card ───────────────────────────────────────────────────────────── */
|
||||
.job-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.job-card.done { border-color: rgba(34,197,94,.25); }
|
||||
.job-card.error { border-color: rgba(239,68,68,.25); }
|
||||
.job-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.job-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text);
|
||||
}
|
||||
.job-status-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: .04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-queued { background: rgba(74,85,104,.4); color: var(--text-dim); }
|
||||
.badge-uploading { background: rgba(37,99,235,.2); color: var(--blue-bright); }
|
||||
.badge-done { background: rgba(34,197,94,.15); color: var(--success); }
|
||||
.badge-error { background: rgba(239,68,68,.15); color: var(--error); }
|
||||
.badge-cancelled { background: rgba(74,85,104,.2); color: var(--text-dim); }
|
||||
|
||||
.job-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.job-speed { color: var(--dragon-light); font-weight: 600; }
|
||||
.job-eta { color: var(--text-mid); }
|
||||
|
||||
.progress-bar-wrap {
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--blue), var(--blue-bright));
|
||||
transition: width .3s;
|
||||
}
|
||||
.progress-bar.done { background: var(--success); }
|
||||
.progress-bar.error { background: var(--error); }
|
||||
|
||||
.job-error-msg { font-size: 11px; color: var(--error); }
|
||||
|
||||
/* ── Settings ───────────────────────────────────────────────────────────── */
|
||||
.settings-section { display: flex; flex-direction: column; gap: 14px; }
|
||||
.settings-group {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.settings-title { font-size: 12px; font-weight: 700; color: var(--text-mid); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.settings-row label { font-size: 12px; color: var(--text); flex: 1; }
|
||||
.settings-row small { font-size: 11px; color: var(--text-dim); display: block; }
|
||||
.settings-input { width: 80px; text-align: center; }
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────────────────────── */
|
||||
.statusbar {
|
||||
height: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #040609;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.statusbar-speed { color: var(--dragon-light); font-weight: 600; }
|
||||
.statusbar-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.statusbar-dot.connected { background: var(--success); }
|
||||
|
||||
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────────────────────── */
|
||||
.toast-wrap {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: all .2s;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.success { border-color: rgba(34,197,94,.4); color: var(--success); }
|
||||
.toast.error { border-color: rgba(239,68,68,.4); color: var(--error); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Title Bar -->
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-controls">
|
||||
<button class="titlebar-btn close" onclick="window.dw.hide()" title="Hide"></button>
|
||||
<button class="titlebar-btn min" onclick="window.dw.minimize()" title="Minimize"></button>
|
||||
</div>
|
||||
<div class="titlebar-logo">🐉 <span>Dragon Wind</span> Desktop</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<div class="nav" id="nav">
|
||||
<div class="nav-tab active" data-page="upload" onclick="switchPage('upload')">Upload</div>
|
||||
<div class="nav-tab" data-page="queue" onclick="switchPage('queue')">Queue <span id="nav-queue-count" style="display:none;font-size:10px;background:var(--blue);color:#fff;border-radius:10px;padding:1px 5px;margin-left:2px"></span></div>
|
||||
<div class="nav-tab" data-page="settings" onclick="switchPage('settings')">Settings</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGIN PAGE (shown instead of nav when logged out) -->
|
||||
<div id="login-page" style="display:none;flex:1;overflow:hidden">
|
||||
<div class="login-wrap">
|
||||
<div style="font-size:36px">🐉</div>
|
||||
<div class="login-title">Dragon Wind</div>
|
||||
<div class="login-sub">Connect to your Dragon Wind server<br>to start uploading.</div>
|
||||
<div class="field">
|
||||
<div class="label">Server URL</div>
|
||||
<input class="input" id="login-url" type="url" placeholder="http://vpm.yourserver.com:3000"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label">Username</div>
|
||||
<input class="input" id="login-user" type="text" placeholder="username"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="label">Password</div>
|
||||
<input class="input" id="login-pass" type="password" placeholder="password"
|
||||
onkeydown="if(event.key==='Enter')doLogin()"/>
|
||||
</div>
|
||||
<div id="login-error" style="color:var(--error);font-size:12px;display:none"></div>
|
||||
<button class="btn btn-primary" id="login-btn" onclick="doLogin()" style="width:100%;max-width:320px;margin-top:4px">Sign In</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UPLOAD PAGE -->
|
||||
<div class="page active" id="page-upload">
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div class="drop-zone" id="drop-zone"
|
||||
onclick="pickAndAdd()"
|
||||
ondragover="onDragOver(event)"
|
||||
ondragleave="onDragLeave(event)"
|
||||
ondrop="onDrop(event)">
|
||||
<span class="drop-zone-icon">📂</span>
|
||||
<div class="drop-zone-label">Drop files here or click to browse</div>
|
||||
<div class="drop-zone-sub">Any file type · up to 50 GB per file</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder selector -->
|
||||
<div class="folder-section">
|
||||
<div class="section-label">Destination Folder</div>
|
||||
<input class="folder-search" id="folder-search" placeholder="🔍 Search folders…" oninput="renderFolders()"/>
|
||||
<div class="folder-list" id="folder-list"></div>
|
||||
<div class="prefix-display" id="prefix-display">(root)</div>
|
||||
</div>
|
||||
|
||||
<!-- Staged files -->
|
||||
<div id="staged-section" style="display:none">
|
||||
<div class="queue-header" style="margin-bottom:6px">
|
||||
<div class="section-label">Files to upload</div>
|
||||
<button class="btn-icon" onclick="clearStaged()" title="Clear all">✕</button>
|
||||
</div>
|
||||
<div id="staged-list" class="queue-list"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" onclick="clearStaged()">Clear</button>
|
||||
<button class="btn btn-dragon" id="upload-btn" onclick="startUpload()">⚡ Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- QUEUE PAGE -->
|
||||
<div class="page" id="page-queue">
|
||||
<div class="queue-header">
|
||||
<div class="section-label">Upload Queue</div>
|
||||
<div class="queue-stats">
|
||||
<span><span class="stat-val" id="stat-active">0</span> active</span>
|
||||
<span><span class="stat-val" id="stat-done">0</span> done</span>
|
||||
<span><span class="stat-val" id="stat-error">0</span> errors</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="queue-list" class="queue-list">
|
||||
<div class="queue-empty">No uploads yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS PAGE -->
|
||||
<div class="page" id="page-settings">
|
||||
<div class="settings-section">
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-title">Connection</div>
|
||||
<div id="conn-status" style="font-size:12px;color:var(--text-mid)">Checking…</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-title">Transfer</div>
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label>Parallel workers</label>
|
||||
<small>Concurrent chunk uploads — more = faster on high-bandwidth links</small>
|
||||
</div>
|
||||
<input class="input settings-input" id="set-workers" type="number" min="1" max="32" value="6"/>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label>Chunk size (MB)</label>
|
||||
<small>Larger chunks = fewer round trips, better on stable connections</small>
|
||||
</div>
|
||||
<input class="input settings-input" id="set-chunk" type="number" min="5" max="256" value="16"/>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="saveSettings()" style="align-self:flex-end">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group" style="font-size:11px;color:var(--text-dim);line-height:1.7">
|
||||
<div class="settings-title">About</div>
|
||||
Dragon Wind Desktop v0.1.0<br>
|
||||
High-speed parallel S3 uploader for Grass Valley AMPP.<br>
|
||||
Built on Electron · Wild Dragon
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="statusbar">
|
||||
<div class="statusbar-dot" id="conn-dot"></div>
|
||||
<span id="sb-server" style="color:var(--text-dim)">Not connected</span>
|
||||
<span style="flex:1"></span>
|
||||
<span class="statusbar-speed" id="sb-speed"></span>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-wrap" id="toast-wrap"></div>
|
||||
|
||||
<script>
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
let folderTree = [];
|
||||
let selectedPrefix = "";
|
||||
let stagedFiles = []; // Array of file paths (strings)
|
||||
let jobs = new Map(); // jobId → job
|
||||
let loggedIn = false;
|
||||
let serverUrl = "";
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
const status = await window.dw.authStatus();
|
||||
if (status.loggedIn) {
|
||||
loggedIn = true;
|
||||
serverUrl = status.serverUrl;
|
||||
showApp();
|
||||
await loadFolders();
|
||||
await refreshQueue();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
|
||||
// Listen for events from main process
|
||||
window.dw.onJobProgress(job => { jobs.set(job.id, job); renderQueue(); updateSpeed(); });
|
||||
window.dw.onJobComplete(job => { jobs.set(job.id, job); renderQueue(); toast(`✅ ${job.name}`, "success"); });
|
||||
window.dw.onJobError(job => { jobs.set(job.id, job); renderQueue(); toast(`❌ ${job.name}: ${job.error}`, "error"); });
|
||||
window.dw.onAddFiles(paths => { stageFiles(paths); switchPage("upload"); });
|
||||
window.dw.onNavSettings(() => switchPage("settings"));
|
||||
|
||||
// Refresh queue periodically
|
||||
setInterval(refreshQueue, 3000);
|
||||
})();
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
async function doLogin() {
|
||||
const url = document.getElementById("login-url").value.trim();
|
||||
const user = document.getElementById("login-user").value.trim();
|
||||
const pass = document.getElementById("login-pass").value;
|
||||
const err = document.getElementById("login-error");
|
||||
const btn = document.getElementById("login-btn");
|
||||
|
||||
if (!url || !user || !pass) { err.textContent = "All fields required."; err.style.display = "block"; return; }
|
||||
err.style.display = "none";
|
||||
btn.disabled = true; btn.textContent = "Signing in…";
|
||||
|
||||
const result = await window.dw.login({ serverUrl: url, username: user, password: pass });
|
||||
btn.disabled = false; btn.textContent = "Sign In";
|
||||
|
||||
if (result.success) {
|
||||
loggedIn = true; serverUrl = url;
|
||||
showApp();
|
||||
await loadFolders();
|
||||
} else {
|
||||
err.textContent = result.error || "Login failed.";
|
||||
err.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await window.dw.logout();
|
||||
loggedIn = false; serverUrl = "";
|
||||
jobs.clear(); stagedFiles = [];
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("login-page").style.display = "flex";
|
||||
document.getElementById("nav").style.display = "none";
|
||||
document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
|
||||
document.getElementById("conn-dot").classList.remove("connected");
|
||||
document.getElementById("sb-server").textContent = "Not connected";
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById("login-page").style.display = "none";
|
||||
document.getElementById("nav").style.display = "flex";
|
||||
switchPage("upload");
|
||||
document.getElementById("conn-dot").classList.add("connected");
|
||||
document.getElementById("sb-server").textContent = serverUrl.replace(/^https?:\/\//, "");
|
||||
document.getElementById("conn-status").textContent = `Connected as ${serverUrl.replace(/^https?:\/\//, "")}`;
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
function switchPage(name) {
|
||||
document.querySelectorAll(".nav-tab").forEach(t => t.classList.toggle("active", t.dataset.page === name));
|
||||
document.querySelectorAll(".page").forEach(p => p.classList.toggle("active", p.id === `page-${name}`));
|
||||
}
|
||||
|
||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||
async function loadFolders() {
|
||||
const r = await window.dw.getFolders();
|
||||
folderTree = r.tree || [];
|
||||
renderFolders();
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
const q = (document.getElementById("folder-search")?.value || "").trim().toLowerCase();
|
||||
const box = document.getElementById("folder-list");
|
||||
box.innerHTML = "";
|
||||
|
||||
function flatMatch(nodes, pathArr) {
|
||||
let out = [];
|
||||
nodes.forEach(n => {
|
||||
const fullPath = [...pathArr, n.name];
|
||||
const key = fullPath.join("/");
|
||||
if (!q || key.toLowerCase().includes(q)) out.push({ key, icon: n.children?.length ? "📁" : "📄" });
|
||||
if (n.children?.length) out = out.concat(flatMatch(n.children, fullPath));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const rows = q ? flatMatch(folderTree, []) : buildTree(folderTree, []);
|
||||
|
||||
if (!q) {
|
||||
// Root row
|
||||
const root = document.createElement("div");
|
||||
root.className = "folder-row" + (selectedPrefix === "" ? " active" : "");
|
||||
root.innerHTML = `<span class="fi">🏠</span> Root`;
|
||||
root.onclick = () => { selectedPrefix = ""; updatePrefix(); renderFolders(); };
|
||||
box.appendChild(root);
|
||||
}
|
||||
|
||||
rows.forEach(({ key, icon, indent = 0 }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "folder-row" + (selectedPrefix === key ? " active" : "");
|
||||
row.style.paddingLeft = (9 + indent * 14) + "px";
|
||||
row.innerHTML = `<span class="fi">${icon}</span> ${esc(q ? key : key.split("/").pop())}`;
|
||||
row.onclick = () => { selectedPrefix = key; updatePrefix(); renderFolders(); };
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTree(nodes, pathArr) {
|
||||
let out = [];
|
||||
nodes.forEach(n => {
|
||||
const fullPath = [...pathArr, n.name];
|
||||
const key = fullPath.join("/");
|
||||
out.push({ key, icon: n.children?.length ? "📁" : "📄", indent: pathArr.length });
|
||||
if (n.children?.length) out = out.concat(buildTree(n.children, fullPath));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function updatePrefix() {
|
||||
document.getElementById("prefix-display").textContent = selectedPrefix || "(root)";
|
||||
}
|
||||
|
||||
// ── File Staging ──────────────────────────────────────────────────────────────
|
||||
async function pickAndAdd() {
|
||||
const paths = await window.dw.pickFiles();
|
||||
if (paths.length) stageFiles(paths);
|
||||
}
|
||||
|
||||
function stageFiles(paths) {
|
||||
paths.forEach(p => { if (!stagedFiles.includes(p)) stagedFiles.push(p); });
|
||||
renderStaged();
|
||||
document.getElementById("staged-section").style.display = stagedFiles.length ? "block" : "none";
|
||||
}
|
||||
|
||||
function clearStaged() {
|
||||
stagedFiles = [];
|
||||
document.getElementById("staged-section").style.display = "none";
|
||||
}
|
||||
|
||||
function renderStaged() {
|
||||
const list = document.getElementById("staged-list");
|
||||
list.innerHTML = "";
|
||||
stagedFiles.forEach((f, i) => {
|
||||
const name = f.split(/[\\/]/).pop();
|
||||
const row = document.createElement("div");
|
||||
row.className = "job-card";
|
||||
row.style.flexDirection = "row";
|
||||
row.style.alignItems = "center";
|
||||
row.innerHTML = `
|
||||
<span style="font-size:11px;font-family:var(--font-mono);color:var(--text-mid);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</span>
|
||||
<button class="btn-icon" onclick="removeStagedFile(${i})">✕</button>`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function removeStagedFile(i) {
|
||||
stagedFiles.splice(i, 1);
|
||||
if (!stagedFiles.length) clearStaged();
|
||||
else renderStaged();
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||
async function startUpload() {
|
||||
if (!stagedFiles.length) return;
|
||||
const btn = document.getElementById("upload-btn");
|
||||
btn.disabled = true; btn.textContent = "Queuing…";
|
||||
|
||||
const result = await window.dw.addFiles({
|
||||
files: stagedFiles,
|
||||
prefix: selectedPrefix,
|
||||
});
|
||||
|
||||
btn.disabled = false; btn.textContent = "⚡ Upload";
|
||||
|
||||
if (result.success) {
|
||||
result.jobs.forEach(j => jobs.set(j.id, j));
|
||||
clearStaged();
|
||||
switchPage("queue");
|
||||
renderQueue();
|
||||
toast(`${result.jobs.length} file${result.jobs.length > 1 ? "s" : ""} queued`, "success");
|
||||
} else {
|
||||
toast(result.error || "Failed to queue", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Queue ─────────────────────────────────────────────────────────────────────
|
||||
async function refreshQueue() {
|
||||
if (!loggedIn) return;
|
||||
const q = await window.dw.getQueue();
|
||||
q.forEach(j => jobs.set(j.id, j));
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
const list = document.getElementById("queue-list");
|
||||
const arr = Array.from(jobs.values()).reverse();
|
||||
|
||||
// Nav badge
|
||||
const active = arr.filter(j => j.status === "uploading" || j.status === "queued").length;
|
||||
const badge = document.getElementById("nav-queue-count");
|
||||
badge.style.display = active ? "inline" : "none";
|
||||
badge.textContent = active;
|
||||
|
||||
// Stats
|
||||
document.getElementById("stat-active").textContent = arr.filter(j => j.status === "uploading").length;
|
||||
document.getElementById("stat-done").textContent = arr.filter(j => j.status === "done").length;
|
||||
document.getElementById("stat-error").textContent = arr.filter(j => j.status === "error").length;
|
||||
|
||||
if (!arr.length) {
|
||||
list.innerHTML = '<div class="queue-empty">No uploads yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
arr.forEach(j => {
|
||||
const card = document.createElement("div");
|
||||
card.className = `job-card ${j.status}`;
|
||||
|
||||
const badgeClass = {
|
||||
queued: "badge-queued", uploading: "badge-uploading",
|
||||
done: "badge-done", error: "badge-error", cancelled: "badge-cancelled",
|
||||
}[j.status] || "badge-queued";
|
||||
|
||||
const badgeLabel = {
|
||||
queued: "QUEUED", uploading: "UPLOADING", done: "DONE",
|
||||
error: "ERROR", cancelled: "CANCELLED",
|
||||
}[j.status] || j.status.toUpperCase();
|
||||
|
||||
const speedStr = j.speedBps > 0 ? fmtSpeed(j.speedBps) : "";
|
||||
const etaStr = j.eta > 0 ? fmtEta(j.eta) : "";
|
||||
const sizeStr = fmtSize(j.size);
|
||||
|
||||
let actions = "";
|
||||
if (j.status === "error") actions = `<button class="btn-icon" onclick="retryJob('${j.id}')" title="Retry">↺</button>`;
|
||||
if (j.status === "uploading" || j.status === "queued")
|
||||
actions = `<button class="btn-icon" onclick="cancelJob('${j.id}')" title="Cancel">✕</button>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="job-top">
|
||||
<div class="job-name">${esc(j.name)}</div>
|
||||
<span class="job-status-badge ${badgeClass}">${badgeLabel}</span>
|
||||
${actions}
|
||||
</div>
|
||||
${j.status === "uploading" || j.status === "done" ? `
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar ${j.status === "done" ? "done" : ""}" style="width:${j.percent}%"></div>
|
||||
</div>
|
||||
` : ""}
|
||||
${j.status === "error" ? `<div class="job-error-msg">${esc(j.error)}</div>` : ""}
|
||||
<div class="job-meta">
|
||||
<span>${sizeStr}</span>
|
||||
${j.prefix ? `<span>${esc(j.prefix)}</span>` : ""}
|
||||
${speedStr ? `<span class="job-speed">${speedStr}</span>` : ""}
|
||||
${etaStr ? `<span class="job-eta">ETA ${etaStr}</span>` : ""}
|
||||
${j.status === "uploading" ? `<span>${j.percent}%</span>` : ""}
|
||||
</div>`;
|
||||
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelJob(id) {
|
||||
await window.dw.cancel(id);
|
||||
const j = jobs.get(id);
|
||||
if (j) { j.status = "cancelled"; renderQueue(); }
|
||||
}
|
||||
|
||||
async function retryJob(id) {
|
||||
await window.dw.retry(id);
|
||||
const j = jobs.get(id);
|
||||
if (j) { j.status = "queued"; j.uploadedBytes = 0; j.percent = 0; renderQueue(); }
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
async function loadSettings() {
|
||||
const s = await window.dw.getSettings();
|
||||
document.getElementById("set-workers").value = s.workers || 6;
|
||||
document.getElementById("set-chunk").value = s.chunkMb || 16;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const workers = parseInt(document.getElementById("set-workers").value);
|
||||
const chunkMb = parseInt(document.getElementById("set-chunk").value);
|
||||
await window.dw.setSettings({ workers, chunkMb });
|
||||
toast("Settings saved", "success");
|
||||
}
|
||||
|
||||
// ── Drag and Drop ─────────────────────────────────────────────────────────────
|
||||
function onDragOver(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById("drop-zone").classList.add("drag-over");
|
||||
}
|
||||
function onDragLeave(e) {
|
||||
document.getElementById("drop-zone").classList.remove("drag-over");
|
||||
}
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById("drop-zone").classList.remove("drag-over");
|
||||
const paths = Array.from(e.dataTransfer.files).map(f => f.path);
|
||||
if (paths.length) stageFiles(paths);
|
||||
}
|
||||
|
||||
// ── Speed display ─────────────────────────────────────────────────────────────
|
||||
function updateSpeed() {
|
||||
const uploading = Array.from(jobs.values()).filter(j => j.status === "uploading");
|
||||
const totalBps = uploading.reduce((s, j) => s + (j.speedBps || 0), 0);
|
||||
document.getElementById("sb-speed").textContent = totalBps > 1024 ? fmtSpeed(totalBps) : "";
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||||
|
||||
function fmtSize(b) {
|
||||
if (!b) return "0 B";
|
||||
if (b < 1024) return b + " B";
|
||||
if (b < 1024*1024) return (b/1024).toFixed(0) + " KB";
|
||||
if (b < 1024*1024*1024) return (b/1024/1024).toFixed(1) + " MB";
|
||||
return (b/1024/1024/1024).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function fmtSpeed(bps) {
|
||||
if (bps < 1024) return bps.toFixed(0) + " B/s";
|
||||
if (bps < 1024*1024) return (bps/1024).toFixed(0) + " KB/s";
|
||||
return (bps/1024/1024).toFixed(1) + " MB/s";
|
||||
}
|
||||
|
||||
function fmtEta(s) {
|
||||
if (s < 60) return s + "s";
|
||||
if (s < 3600) return Math.floor(s/60) + "m " + (s%60) + "s";
|
||||
return Math.floor(s/3600) + "h " + Math.floor((s%3600)/60) + "m";
|
||||
}
|
||||
|
||||
function toast(msg, type = "info") {
|
||||
const wrap = document.getElementById("toast-wrap");
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast ${type}`;
|
||||
el.textContent = msg;
|
||||
wrap.appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add("show"));
|
||||
setTimeout(() => {
|
||||
el.classList.remove("show");
|
||||
setTimeout(() => el.remove(), 250);
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue