DragonWind/chrome-extension/popup.js
Zac Gaetano 641701edf8 feat: Dragon Wind v1.0 — dual-mode broadcast uploader
- Full VPM Uploader feature set (auth, users, folders, AMPP monitor)
- HTTP upload via presigned S3 URLs with XHR progress tracking
- UDP upload mode with relay server (WebRTC DataChannel + HTTP fallback)
- S3 Admin settings with live Test Connection (upload+delete verify)
- UDP Relay Admin settings with health check
- Standalone UDP relay server (Node.js + Docker) with multipart S3 assembly
- Chrome Extension (Manifest v3): popup, background, content script
- Dynamic S3 client — reconfigures on save without restart
- Dark/light theme, full AMPP job monitor
- docker-compose.yml with dragon-wind + udp-relay services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:05:34 -04:00

310 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
// Dragon Wind Chrome Extension — Popup Script
let config = { serverUrl: 'http://localhost:3000', username: 'Admin', password: '' };
let authToken = null;
let uploadMode = 'http';
let selectedFiles = [];
let folderList = [];
let connected = false;
// ==================== INIT ====================
(async function init() {
const stored = await chrome.storage.local.get(['config', 'token', 'mode']);
if (stored.config) config = { ...config, ...stored.config };
if (stored.token) authToken = stored.token;
if (stored.mode) uploadMode = stored.mode;
document.getElementById('cfg-server').value = config.serverUrl;
document.getElementById('cfg-user').value = config.username;
document.getElementById('conn-server').textContent = config.serverUrl.replace(/https?:\/\//, '');
setMode(uploadMode, false);
await tryConnect();
})();
document.getElementById('settings-toggle').addEventListener('click', () => {
const panel = document.getElementById('settings-panel');
panel.classList.toggle('open');
});
// ==================== CONNECTION ====================
async function tryConnect() {
setConnStatus('grey', 'Connecting…');
if (!authToken) {
if (!config.username || !config.password) {
setConnStatus('red', 'Set credentials in settings');
return;
}
await login();
return;
}
try {
const r = await apiFetch('GET', '/api/health');
if (r.status === 'ok') {
setConnStatus('green', `Connected — ${r.s3Configured ? 'S3 ✓' : 'S3 not configured'}`);
connected = true;
await loadFolders();
} else {
authToken = null;
await chrome.storage.local.remove('token');
await login();
}
} catch (e) {
setConnStatus('red', `Cannot reach server`);
}
}
async function login() {
try {
const r = await apiFetch('POST', '/api/login', { username: config.username, password: config.password });
if (r.success) {
authToken = r.token;
await chrome.storage.local.set({ token: authToken });
setConnStatus('green', `Connected as ${r.user}`);
connected = true;
await loadFolders();
} else {
setConnStatus('red', r.error || 'Auth failed');
}
} catch (e) {
setConnStatus('red', 'Connection failed');
}
}
function setConnStatus(color, text) {
const dot = document.getElementById('conn-dot');
dot.className = `conn-dot ${color}`;
document.getElementById('conn-label').textContent = text;
}
// ==================== SETTINGS ====================
async function saveSettings() {
const serverUrl = document.getElementById('cfg-server').value.trim().replace(/\/$/, '');
const username = document.getElementById('cfg-user').value.trim();
const password = document.getElementById('cfg-pass').value;
if (!serverUrl) { showSettingsStatus('Server URL required', 'error'); return; }
config = { serverUrl, username, password: password || config.password };
authToken = null;
await chrome.storage.local.set({ config, token: null });
document.getElementById('conn-server').textContent = serverUrl.replace(/https?:\/\//, '');
showSettingsStatus('Saved — connecting…', 'loading');
connected = false;
await tryConnect();
showSettingsStatus('Settings saved', 'success');
}
function showSettingsStatus(msg, type) {
const el = document.getElementById('settings-status');
el.className = `status-bar ${type}`;
el.textContent = msg;
}
// ==================== FOLDERS ====================
async function loadFolders() {
try {
const d = await apiFetch('GET', '/api/folders');
folderList = [];
flattenTree(d.tree || [], '', folderList);
const sel = document.getElementById('folder-select');
const current = sel.value;
sel.innerHTML = '<option value="">Root</option>';
folderList.forEach(f => {
const opt = document.createElement('option');
opt.value = f.path; opt.textContent = f.display;
sel.appendChild(opt);
});
if (current) sel.value = current;
} catch (_) {}
}
function flattenTree(nodes, prefix, out) {
nodes.forEach(n => {
const path = prefix ? `${prefix}/${n.name}` : n.name;
const display = '\u00a0'.repeat(prefix.split('/').filter(Boolean).length * 2) + (prefix ? '↳ ' : '') + n.name;
out.push({ path, display });
if (n.children.length) flattenTree(n.children, path, out);
});
}
// ==================== MODE ====================
function setMode(mode, save = true) {
uploadMode = mode;
document.getElementById('btn-http').className = `mode-btn${mode === 'http' ? ' active-http' : ''}`;
document.getElementById('btn-udp').className = `mode-btn${mode === 'udp' ? ' active-udp' : ''}`;
const btn = document.getElementById('upload-btn');
if (mode === 'http') { btn.className = 'upload-btn http'; btn.textContent = 'Upload Files'; }
else { btn.className = 'upload-btn'; btn.textContent = '⚡ UDP Upload'; }
if (save) chrome.storage.local.set({ mode });
}
// ==================== FILES ====================
function onDrop(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.remove('over');
addFiles(Array.from(e.dataTransfer.files));
}
function onFileInputChange(e) { addFiles(Array.from(e.target.files)); e.target.value = ''; }
function addFiles(files) {
files.forEach(f => {
if (!selectedFiles.find(x => x.name === f.name && x.size === f.size)) {
selectedFiles.push({ file: f, name: f.name, size: f.size, status: 'pending' });
}
});
renderFileList();
updateBtn();
}
function renderFileList() {
const list = document.getElementById('file-list');
list.innerHTML = '';
selectedFiles.forEach((item, i) => {
const el = document.createElement('div');
el.className = 'file-item';
el.innerHTML = `
<span class="fi-icon">${getIcon(item.name)}</span>
<div class="fi-info">
<div class="fi-name">${esc(item.name)}</div>
<div class="fi-size">${fmtSize(item.size)}</div>
<div class="fi-prog" id="fp-${i}" style="display:none"><div class="fi-prog-bar${uploadMode==='udp'?' udp':''}" id="fpb-${i}" style="width:0%"></div></div>
</div>
<span class="fi-status ${item.status}" id="fs-${i}">${item.status}</span>
<button class="fi-rm" onclick="removeFile(${i})">×</button>
`;
list.appendChild(el);
});
}
function removeFile(i) { selectedFiles.splice(i, 1); renderFileList(); updateBtn(); }
function updateBtn() {
const n = selectedFiles.filter(f => f.status === 'pending').length;
const btn = document.getElementById('upload-btn');
btn.disabled = n === 0 || !connected;
if (uploadMode === 'udp') btn.textContent = n > 0 ? `⚡ UDP Upload (${n})` : '⚡ UDP Upload';
else btn.textContent = n > 0 ? `Upload ${n} File${n>1?'s':''}` : 'Upload Files';
}
// ==================== UPLOAD ====================
async function startUpload() {
if (!connected) { showStatus('Not connected to server', 'error'); return; }
const pending = selectedFiles.filter(f => f.status === 'pending');
document.getElementById('upload-btn').disabled = true;
const prefix = document.getElementById('folder-select').value;
let done = 0, failed = 0;
for (const item of pending) {
const idx = selectedFiles.indexOf(item);
setFileStatus(idx, 'uploading', 'Uploading…');
document.getElementById(`fp-${idx}`).style.display = 'block';
try {
if (uploadMode === 'http') await uploadHTTP(item, idx, prefix);
else await uploadUDP(item, idx, prefix);
setFileStatus(idx, 'done', '✓');
item.status = 'done';
done++;
} catch (e) {
setFileStatus(idx, 'error', '✗');
item.status = 'error';
failed++;
console.error(`Upload failed for ${item.name}:`, e);
}
}
const total = done + failed;
if (failed === 0) showStatus(`${done} file${done>1?'s':''} uploaded`, 'success');
else showStatus(`${done}/${total} uploaded, ${failed} failed`, failed === total ? 'error' : 'loading');
updateBtn();
}
async function uploadHTTP(item, idx, prefix) {
const presigned = await apiFetch('POST', '/api/presigned', {
filename: item.name, prefix,
contentType: item.file.type || 'application/octet-stream'
});
if (!presigned.success) throw new Error(presigned.error);
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presigned.url);
xhr.setRequestHeader('Content-Type', item.file.type || 'application/octet-stream');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById(`fpb-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
};
xhr.onload = () => xhr.status < 300 ? resolve() : reject(new Error(`S3 ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(item.file);
});
document.getElementById(`fpb-${idx}`).style.width = '100%';
}
async function uploadUDP(item, idx, prefix) {
// Create UDP session on server
const sessResp = await apiFetch('POST', '/api/udp/session', { filename: item.name, size: item.size, prefix });
if (!sessResp.success) throw new Error(sessResp.error);
const { sessionId, relayUrl } = sessResp;
const CHUNK = 64 * 1024;
const totalChunks = Math.ceil(item.size / CHUNK);
// Send via chunked HTTP fallback (true UDP requires chrome.sockets which needs host app)
for (let i = 0; i < totalChunks; i++) {
const chunk = item.file.slice(i * CHUNK, (i + 1) * CHUNK);
const resp = await fetch(`${relayUrl}/session/${sessionId}/chunk/${i}`, {
method: 'POST', body: chunk, headers: { 'Content-Type': 'application/octet-stream' }
});
if (!resp.ok) throw new Error(`Chunk ${i} failed`);
const pct = Math.round(((i + 1) / totalChunks) * 100);
document.getElementById(`fpb-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
await apiFetch('POST', `/api/udp/session/${sessionId}/complete`, { success: true });
document.getElementById(`fpb-${idx}`).style.width = '100%';
}
// ==================== HELPERS ====================
async function apiFetch(method, path, body) {
const url = config.serverUrl.replace(/\/$/, '') + path;
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (authToken) opts.headers['x-auth-token'] = authToken;
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
if (r.status === 401) { authToken = null; await chrome.storage.local.remove('token'); throw new Error('Session expired'); }
return r.json();
}
function showStatus(msg, type) {
const el = document.getElementById('status-bar');
el.className = `status-bar ${type}`;
el.textContent = msg;
}
function setFileStatus(i, cls, text) {
const el = document.getElementById(`fs-${i}`);
if (el) { el.className = `fi-status ${cls}`; el.textContent = text; }
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSize(b) {
if (b < 1024) return `${b}B`;
if (b < 1048576) return `${(b/1024).toFixed(0)}KB`;
if (b < 1073741824) return `${(b/1048576).toFixed(1)}MB`;
return `${(b/1073741824).toFixed(2)}GB`;
}
function getIcon(name) {
const ext = name.split('.').pop().toLowerCase();
if (['mp4','mov','mxf','mkv','avi','r3d','braw','mts','m2ts'].includes(ext)) return '🎬';
if (['mp3','wav','aac','flac','aiff','m4a'].includes(ext)) return '🎵';
if (['jpg','jpeg','png','tiff','exr','dpx','arw','cr2','dng'].includes(ext)) return '🖼️';
return '📄';
}