311 lines
11 KiB
JavaScript
311 lines
11 KiB
JavaScript
|
|
"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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|||
|
|
|
|||
|
|
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 '📄';
|
|||
|
|
}
|