Add integrated downloads manager and UI
Implements a full downloads manager with Electron main process handling, IPC APIs, and renderer integration. Adds a dedicated downloads page, a mini downloads popup in the navigation bar with progress ring, and controls for pausing, resuming, canceling, opening, and showing downloads. Updates styles and navigation to support the new downloads features.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, ipcMain, session, screen, shell, dialog, Menu, clipboard } = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, session, screen, shell, dialog, Menu, clipboard, webContents } = require('electron');
|
||||
const { pathToFileURL } = require('url');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -317,6 +317,16 @@ app.whenReady().then(() => {
|
||||
// Defer session configuration to microtask/next tick (already inexpensive) – keep explicit
|
||||
setImmediate(configureSessionsAsync);
|
||||
|
||||
// Register download handlers for common sessions
|
||||
try {
|
||||
const mainSes = session.fromPartition('persist:main');
|
||||
const defSes = session.defaultSession;
|
||||
if (mainSes) registerDownloadHandling(mainSes);
|
||||
if (defSes && defSes !== mainSes) registerDownloadHandling(defSes);
|
||||
} catch (e) {
|
||||
console.warn('Failed to register download handlers:', e);
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock.setIcon(path.join(__dirname, 'assets/images/Logos/Nebula-Icon.icns'));
|
||||
}
|
||||
@@ -669,6 +679,10 @@ function buildAndShowContextMenu(sender, params = {}) {
|
||||
if (linkURL) {
|
||||
template.push(
|
||||
{ label: 'Open Link in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-link-new-tab', url: linkURL }) },
|
||||
{ label: 'Download Link', click: () => {
|
||||
try { (sender.hostWebContents || sender).downloadURL(linkURL); } catch (e) { console.error('downloadURL failed:', e); }
|
||||
}
|
||||
},
|
||||
{ label: 'Open Link Externally', click: () => shell.openExternal(linkURL).catch(()=>{}) },
|
||||
{ label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) },
|
||||
{ type: 'separator' }
|
||||
@@ -811,3 +825,191 @@ ipcMain.handle('save-image-from-url', async (event, { url }) => {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// =========================
|
||||
// Download manager plumbing
|
||||
// =========================
|
||||
|
||||
// In-memory download registry
|
||||
const downloads = new Map(); // id -> { id, url, filename, savePath, totalBytes, receivedBytes, state, startedAt, mime, canResume, paused }
|
||||
|
||||
function broadcastToAll(channel, payload) {
|
||||
try {
|
||||
for (const wc of webContents.getAllWebContents()) {
|
||||
try { wc.send(channel, payload); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to windows only
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
try { win.webContents.send(channel, payload); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerDownloadHandling(ses) {
|
||||
if (!ses || ses.__nebulaDownloadsHooked) return;
|
||||
ses.__nebulaDownloadsHooked = true;
|
||||
ses.on('will-download', async (event, item, wc) => {
|
||||
try {
|
||||
// Build an id (prefer stable GUID if available)
|
||||
const id = typeof item.getGUID === 'function' ? item.getGUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
item.__nebulaId = id;
|
||||
const filename = item.getFilename();
|
||||
const mime = item.getMimeType?.() || 'application/octet-stream';
|
||||
const totalBytes = item.getTotalBytes();
|
||||
const url = item.getURL();
|
||||
|
||||
// Choose a default save path under user's Downloads, ensure unique to avoid overwrite
|
||||
const defaultDir = app.getPath('downloads');
|
||||
const uniquePath = await computeUniqueSavePath(defaultDir, filename);
|
||||
try { item.setSavePath(uniquePath); } catch {}
|
||||
|
||||
const info = {
|
||||
id, url, filename,
|
||||
savePath: uniquePath,
|
||||
totalBytes,
|
||||
receivedBytes: 0,
|
||||
state: 'in-progress',
|
||||
startedAt: Date.now(),
|
||||
mime,
|
||||
canResume: false,
|
||||
paused: false
|
||||
};
|
||||
downloads.set(id, { ...info, item });
|
||||
const payload = { ...info };
|
||||
broadcastToAll('downloads-started', payload);
|
||||
|
||||
item.on('updated', (e, state) => {
|
||||
const d = downloads.get(id);
|
||||
if (!d) return;
|
||||
d.receivedBytes = item.getReceivedBytes();
|
||||
d.canResume = !!item.canResume?.();
|
||||
d.paused = !!item.isPaused?.();
|
||||
d.state = state === 'interrupted' ? 'interrupted' : 'in-progress';
|
||||
downloads.set(id, d);
|
||||
broadcastToAll('downloads-updated', {
|
||||
id,
|
||||
receivedBytes: d.receivedBytes,
|
||||
totalBytes: d.totalBytes,
|
||||
state: d.state,
|
||||
canResume: d.canResume,
|
||||
paused: d.paused
|
||||
});
|
||||
});
|
||||
|
||||
item.once('done', (e, state) => {
|
||||
const d = downloads.get(id) || {};
|
||||
const finalState = state === 'completed' ? 'completed' : (state === 'cancelled' ? 'cancelled' : 'interrupted');
|
||||
const final = {
|
||||
id,
|
||||
url,
|
||||
filename,
|
||||
savePath: item.getSavePath?.() || d.savePath,
|
||||
totalBytes: d.totalBytes || item.getTotalBytes?.() || 0,
|
||||
receivedBytes: item.getReceivedBytes?.() || d.receivedBytes || 0,
|
||||
state: finalState,
|
||||
startedAt: d.startedAt || Date.now(),
|
||||
endedAt: Date.now(),
|
||||
mime
|
||||
};
|
||||
// Store minimal object; drop live item ref
|
||||
downloads.set(id, final);
|
||||
broadcastToAll('downloads-done', final);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('will-download handler error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function computeUniqueSavePath(dir, baseName) {
|
||||
try {
|
||||
const target = path.join(dir, baseName);
|
||||
try {
|
||||
await fs.promises.access(target);
|
||||
// Already exists, create a (n) suffix
|
||||
const { name, ext } = splitNameExt(baseName);
|
||||
for (let i = 1; i < 10000; i++) {
|
||||
const candidate = path.join(dir, `${name} (${i})${ext}`);
|
||||
try { await fs.promises.access(candidate); } catch { return candidate; }
|
||||
}
|
||||
// Fallback if too many
|
||||
return path.join(dir, `${Date.now()}-${baseName}`);
|
||||
} catch {
|
||||
return target; // does not exist
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to temp directory
|
||||
return path.join(app.getPath('downloads'), `${Date.now()}-${baseName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function splitNameExt(filename) {
|
||||
const ext = path.extname(filename);
|
||||
const name = filename.slice(0, filename.length - ext.length);
|
||||
return { name, ext };
|
||||
}
|
||||
|
||||
// IPC: list downloads
|
||||
ipcMain.handle('downloads-get-all', () => {
|
||||
return Array.from(downloads.values()).map(d => {
|
||||
const { item, ...rest } = d;
|
||||
if (item) {
|
||||
return {
|
||||
...rest,
|
||||
receivedBytes: item.getReceivedBytes?.() ?? rest.receivedBytes ?? 0,
|
||||
totalBytes: item.getTotalBytes?.() ?? rest.totalBytes ?? 0,
|
||||
state: rest.state || 'in-progress',
|
||||
paused: item.isPaused?.() || false,
|
||||
canResume: item.canResume?.() || false
|
||||
};
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
|
||||
// IPC: control a download (pause/resume/cancel/open/show)
|
||||
ipcMain.handle('downloads-action', async (event, { id, action }) => {
|
||||
const d = downloads.get(id);
|
||||
if (!d) return false;
|
||||
const item = d.item;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
if (item && !item.isPaused?.()) item.pause?.();
|
||||
return true;
|
||||
case 'resume':
|
||||
if (item && item.canResume?.()) item.resume?.();
|
||||
return true;
|
||||
case 'cancel':
|
||||
if (item && d.state === 'in-progress') item.cancel?.();
|
||||
return true;
|
||||
case 'open-file':
|
||||
if (d.savePath) {
|
||||
await shell.openPath(d.savePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case 'show-in-folder':
|
||||
if (d.savePath) {
|
||||
shell.showItemInFolder(d.savePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('downloads-action error:', e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// IPC: clear completed entries from the registry (keeps in-progress)
|
||||
ipcMain.handle('downloads-clear-completed', () => {
|
||||
for (const [id, d] of downloads.entries()) {
|
||||
if (d.state === 'completed' || d.state === 'cancelled') downloads.delete(id);
|
||||
}
|
||||
broadcastToAll('downloads-cleared');
|
||||
return true;
|
||||
});
|
||||
|
||||
+11
@@ -119,4 +119,15 @@ contextBridge.exposeInMainWorld('aboutAPI', {
|
||||
// Relay context-menu commands from main to active renderer context (open new tabs etc.)
|
||||
ipcRenderer.on('context-menu-command', (event, payload) => {
|
||||
window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload }));
|
||||
});
|
||||
|
||||
// Downloads API exposed to renderer
|
||||
contextBridge.exposeInMainWorld('downloadsAPI', {
|
||||
list: () => ipcRenderer.invoke('downloads-get-all'),
|
||||
action: (id, action) => ipcRenderer.invoke('downloads-action', { id, action }),
|
||||
clearCompleted: () => ipcRenderer.invoke('downloads-clear-completed'),
|
||||
onStarted: (handler) => ipcRenderer.on('downloads-started', (_e, payload) => handler(payload)),
|
||||
onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)),
|
||||
onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)),
|
||||
onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler)
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Downloads</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="performance.css" />
|
||||
<style>
|
||||
body { font-family: system-ui, Segoe UI, Roboto, sans-serif; margin: 16px; color: #eee; background: #121212; }
|
||||
h1 { font-size: 20px; margin: 0 0 12px; }
|
||||
.download-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.download-item { background: #1e1e1e; border: 1px solid #2a2a2a; border-radius: 8px; padding: 10px 12px; display: grid; grid-template-columns: 1fr auto; gap: 6px 12px; align-items: center; }
|
||||
.file { font-weight: 600; color: #fafafa; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.meta { font-size: 12px; color: #bbb; }
|
||||
.progress { height: 6px; background: #2a2a2a; border-radius: 4px; overflow: hidden; grid-column: 1 / -1; }
|
||||
.bar { height: 100%; background: #3b82f6; width: 0%; transition: width .15s linear; }
|
||||
.actions { display: flex; gap: 6px; }
|
||||
button { background: #2b2b2b; border: 1px solid #3a3a3a; color: #eee; border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
||||
button:hover { background: #333; }
|
||||
.state { font-size: 12px; color: #aaa; }
|
||||
.row { display: flex; gap: 12px; justify-content: space-between; align-items: center; }
|
||||
.empty { color: #888; font-style: italic; padding: 20px; text-align: center; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<h1>Downloads</h1>
|
||||
<div>
|
||||
<button id="clear-completed">Clear Completed</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="list" class="download-list"></div>
|
||||
<div id="empty" class="empty" style="display:none;">No downloads yet</div>
|
||||
|
||||
<script>
|
||||
const api = window.downloadsAPI;
|
||||
const listEl = document.getElementById('list');
|
||||
const emptyEl = document.getElementById('empty');
|
||||
const clearBtn = document.getElementById('clear-completed');
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n || n <= 0) return '0 B';
|
||||
const u = ['B','KB','MB','GB','TB'];
|
||||
const i = Math.floor(Math.log(n)/Math.log(1024));
|
||||
return (n/Math.pow(1024,i)).toFixed( i===0 ? 0 : 1 ) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function rowHtml(d){
|
||||
const pct = d.totalBytes > 0 ? Math.min(100, Math.round((d.receivedBytes||0) * 100 / d.totalBytes)) : 0;
|
||||
return `
|
||||
<div class="download-item" id="dl-${d.id}">
|
||||
<div class="file" title="${d.filename}">${d.filename}</div>
|
||||
<div class="actions">
|
||||
${d.state==='in-progress' ? `
|
||||
<button data-act="${d.paused?'resume':'pause'}" data-id="${d.id}">${d.paused?'Resume':'Pause'}</button>
|
||||
<button data-act="cancel" data-id="${d.id}">Cancel</button>
|
||||
` : `
|
||||
<button data-act="open-file" data-id="${d.id}" ${d.state!=='completed'?'disabled':''}>Open</button>
|
||||
<button data-act="show-in-folder" data-id="${d.id}">Show in Folder</button>
|
||||
`}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="state">${d.state}</span>
|
||||
· ${fmtBytes(d.receivedBytes||0)} / ${fmtBytes(d.totalBytes||0)}
|
||||
</div>
|
||||
<div class="progress"><div class="bar" style="width:${pct}%"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function render(entries){
|
||||
listEl.innerHTML = entries.map(rowHtml).join('');
|
||||
emptyEl.style.display = entries.length ? 'none' : 'block';
|
||||
}
|
||||
|
||||
async function refresh(){
|
||||
const all = await api.list();
|
||||
// Newest first by start time
|
||||
all.sort((a,b)=> (b.startedAt||0)-(a.startedAt||0));
|
||||
render(all);
|
||||
}
|
||||
|
||||
listEl.addEventListener('click', async (e)=>{
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const id = btn.getAttribute('data-id');
|
||||
const act = btn.getAttribute('data-act');
|
||||
if (!id || !act) return;
|
||||
await api.action(id, act);
|
||||
if (act==='cancel') refresh();
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', async ()=>{
|
||||
await api.clearCompleted();
|
||||
refresh();
|
||||
});
|
||||
|
||||
api.onStarted(()=> refresh());
|
||||
api.onUpdated(()=> {
|
||||
// Partial update: just patch progress bar and bytes if present
|
||||
// For simplicity now, refresh list
|
||||
refresh();
|
||||
});
|
||||
api.onDone(()=> refresh());
|
||||
api.onCleared(()=> refresh());
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+17
-1
@@ -40,7 +40,23 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button title="Downloads">⭳</button>
|
||||
<div class="downloads-wrapper">
|
||||
<button id="downloads-btn" title="Downloads" aria-label="Downloads">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12"/>
|
||||
<path d="M7 10l5 5 5-5"/>
|
||||
<path d="M5 21h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="downloads-popup" class="hidden">
|
||||
<div class="downloads-pop-header">
|
||||
<span>Downloads</span>
|
||||
<button id="downloads-show-all">Show all</button>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-pop-list"></div>
|
||||
<div id="downloads-empty" class="downloads-empty">No downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-wrapper">
|
||||
<button id="menu-btn">☰</button>
|
||||
<div id="menu-popup" class="hidden">
|
||||
|
||||
+136
-1
@@ -52,7 +52,7 @@ urlBox.addEventListener('keydown', (e) => {
|
||||
|
||||
let tabs = [];
|
||||
let activeTabId = null;
|
||||
const allowedInternalPages = ['settings', 'home'];
|
||||
const allowedInternalPages = ['settings', 'home', 'downloads'];
|
||||
let bookmarks = [];
|
||||
|
||||
// Efficient render scheduling to avoid redundant DOM work
|
||||
@@ -130,6 +130,8 @@ ipcRenderer.on('record-site-history', (event, url) => {
|
||||
addToSiteHistory(url);
|
||||
});
|
||||
|
||||
// Auto-open on download start is disabled by design now.
|
||||
|
||||
function createTab(inputUrl) {
|
||||
inputUrl = inputUrl || 'browser://home';
|
||||
debug('[DEBUG] createTab() inputUrl =', inputUrl);
|
||||
@@ -790,9 +792,21 @@ function openSettings() {
|
||||
createTab('browser://settings');
|
||||
}
|
||||
|
||||
// Open Downloads manager page
|
||||
function openDownloads() {
|
||||
createTab('browser://downloads');
|
||||
}
|
||||
|
||||
// Toggle menu dropdown
|
||||
const menuBtn = document.getElementById('menu-btn');
|
||||
const menuWrapper = document.querySelector('.menu-wrapper');
|
||||
// Downloads mini popup elements
|
||||
let downloadsBtnEl = null;
|
||||
let downloadsPopupEl = null;
|
||||
let downloadsListEl = null;
|
||||
let downloadsEmptyEl = null;
|
||||
let downloadsShowAllBtn = null;
|
||||
let ringSvgEl = null;
|
||||
|
||||
// Open/close on button click; stop propagation so outside-click handler doesn't immediately close it
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
@@ -821,6 +835,9 @@ document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && menuPopup && !menuPopup.classList.contains('hidden')) {
|
||||
menuPopup.classList.add('hidden');
|
||||
}
|
||||
if (e.key === 'Escape' && downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) {
|
||||
hideDownloadsPopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Also close when interacting with main content areas (covers webview clicks)
|
||||
@@ -919,6 +936,40 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
const settingsBtn = document.getElementById('open-settings-btn');
|
||||
if (settingsBtn) settingsBtn.addEventListener('click', openSettings);
|
||||
|
||||
// downloads button
|
||||
downloadsBtnEl = document.getElementById('downloads-btn');
|
||||
downloadsPopupEl = document.getElementById('downloads-popup');
|
||||
downloadsListEl = document.getElementById('downloads-list');
|
||||
downloadsEmptyEl = document.getElementById('downloads-empty');
|
||||
downloadsShowAllBtn = document.getElementById('downloads-show-all');
|
||||
if (downloadsBtnEl) {
|
||||
// Insert progress ring SVG
|
||||
const ring = document.createElement('div');
|
||||
ring.className = 'ring';
|
||||
ring.innerHTML = '<svg viewBox="0 0 40 40" aria-hidden="true"><circle class="bg" cx="20" cy="20" r="16.5"></circle><circle class="fg" cx="20" cy="20" r="16.5" stroke-dasharray="103.67" stroke-dashoffset="103.67"></circle></svg>';
|
||||
downloadsBtnEl.appendChild(ring);
|
||||
ringSvgEl = ring.querySelector('circle.fg');
|
||||
downloadsBtnEl.addEventListener('click', (e)=>{
|
||||
e.stopPropagation();
|
||||
toggleDownloadsPopup();
|
||||
});
|
||||
}
|
||||
if (downloadsShowAllBtn) downloadsShowAllBtn.addEventListener('click', ()=> { hideDownloadsPopup(); openDownloads(); });
|
||||
// Close popup if clicking elsewhere
|
||||
document.addEventListener('click', (e)=>{
|
||||
if (!downloadsPopupEl || downloadsPopupEl.classList.contains('hidden')) return;
|
||||
const wrapper = downloadsPopupEl.parentElement;
|
||||
if (wrapper && !wrapper.contains(e.target)) hideDownloadsPopup();
|
||||
});
|
||||
|
||||
// Initialize list with any existing downloads
|
||||
refreshDownloadsMini();
|
||||
// Subscribe to updates
|
||||
window.downloadsAPI?.onStarted(()=> { refreshDownloadsMini(); });
|
||||
window.downloadsAPI?.onUpdated(()=> { refreshDownloadsMini(); });
|
||||
window.downloadsAPI?.onDone(()=> { refreshDownloadsMini(); });
|
||||
window.downloadsAPI?.onCleared(()=> { refreshDownloadsMini(); });
|
||||
|
||||
// window control bindings
|
||||
const minBtn = document.getElementById('min-btn');
|
||||
const maxBtn = document.getElementById('max-btn');
|
||||
@@ -995,6 +1046,9 @@ function attachCloseMenuOnInteract(el) {
|
||||
if (menuPopup && !menuPopup.classList.contains('hidden')) {
|
||||
menuPopup.classList.add('hidden');
|
||||
}
|
||||
if (downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) {
|
||||
hideDownloadsPopup();
|
||||
}
|
||||
};
|
||||
el.addEventListener('mousedown', closeIfOpen);
|
||||
el.addEventListener('pointerdown', closeIfOpen);
|
||||
@@ -1059,3 +1113,84 @@ window.addEventListener('nebula-context-command', (e) => {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------
|
||||
// Downloads mini UI helpers
|
||||
// ------------------------------
|
||||
function toggleDownloadsPopup() {
|
||||
if (!downloadsPopupEl) return;
|
||||
if (downloadsPopupEl.classList.contains('hidden')) showDownloadsPopup(); else hideDownloadsPopup();
|
||||
}
|
||||
function showDownloadsPopup() {
|
||||
if (!downloadsPopupEl) return;
|
||||
downloadsPopupEl.classList.remove('hidden');
|
||||
}
|
||||
function hideDownloadsPopup() {
|
||||
if (!downloadsPopupEl) return;
|
||||
downloadsPopupEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
function fmtBytesMini(n) {
|
||||
if (!n || n <= 0) return '0 B';
|
||||
const u = ['B','KB','MB','GB','TB'];
|
||||
const i = Math.floor(Math.log(n)/Math.log(1024));
|
||||
return (n/Math.pow(1024,i)).toFixed(i===0?0:1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
async function refreshDownloadsMini() {
|
||||
if (!window.downloadsAPI) return;
|
||||
const items = await window.downloadsAPI.list();
|
||||
const has = items && items.length > 0;
|
||||
if (downloadsEmptyEl) downloadsEmptyEl.style.display = has ? 'none' : 'block';
|
||||
if (downloadsListEl) downloadsListEl.innerHTML = (items||[]).slice(0,5).map(d => {
|
||||
const pct = d.totalBytes > 0 ? Math.min(100, Math.round((d.receivedBytes||0)*100/d.totalBytes)) : (d.state==='completed'?100:0);
|
||||
return `
|
||||
<div class="dl-item" data-id="${d.id}">
|
||||
<div class="dl-file" title="${d.filename}">${d.filename}</div>
|
||||
<div class="dl-actions">
|
||||
${d.state==='in-progress' ? `
|
||||
<button data-act="${d.paused?'resume':'pause'}">${d.paused?'Resume':'Pause'}</button>
|
||||
<button data-act="cancel">Cancel</button>
|
||||
` : `
|
||||
<button data-act="open-file" ${d.state!=='completed'?'disabled':''}>Open</button>
|
||||
<button data-act="show-in-folder">Show</button>
|
||||
`}
|
||||
</div>
|
||||
<div class="dl-meta">${d.state} · ${fmtBytesMini(d.receivedBytes||0)} / ${fmtBytesMini(d.totalBytes||0)}</div>
|
||||
<div class="dl-progress"><div class="dl-bar" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (downloadsListEl) {
|
||||
downloadsListEl.onclick = async (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const itemEl = btn.closest('.dl-item');
|
||||
const id = itemEl?.getAttribute('data-id');
|
||||
const act = btn.getAttribute('data-act');
|
||||
if (!id || !act) return;
|
||||
await window.downloadsAPI.action(id, act);
|
||||
if (act==='cancel') refreshDownloadsMini();
|
||||
};
|
||||
}
|
||||
|
||||
updateDownloadsRing(items||[]);
|
||||
}
|
||||
|
||||
function updateDownloadsRing(items) {
|
||||
if (!ringSvgEl) return;
|
||||
// Compute aggregate progress for in-progress downloads
|
||||
const inprog = items.filter(d => d.state === 'in-progress');
|
||||
const total = inprog.reduce((a,d)=> a + (d.totalBytes||0), 0);
|
||||
const done = inprog.reduce((a,d)=> a + (d.receivedBytes||0), 0);
|
||||
let pct = 0;
|
||||
if (total > 0) pct = Math.max(0, Math.min(1, done/total));
|
||||
// If none in progress but some completed recently, show full ring briefly; else hide
|
||||
const circumference = 103.67; // 2 * PI * r (r=16.5)
|
||||
const offset = circumference * (1 - pct);
|
||||
ringSvgEl.style.strokeDasharray = `${circumference}`;
|
||||
ringSvgEl.style.strokeDashoffset = `${offset}`;
|
||||
// Hide ring when no active downloads
|
||||
const show = inprog.length > 0;
|
||||
ringSvgEl.style.opacity = show ? '1' : '0';
|
||||
}
|
||||
|
||||
@@ -170,6 +170,44 @@ html, body {
|
||||
/* subtle inner highlight adds edge definition */
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
|
||||
line-height: 0; /* avoid vertical misalignment for glyphs */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#downloads-btn svg { display:block; width: 18px; height: 18px; }
|
||||
|
||||
/* Downloads button chrome to match other nav buttons */
|
||||
#downloads-btn {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(50,54,74,0.96), rgba(38,42,60,0.96)) padding-box,
|
||||
linear-gradient(180deg, rgba(255,255,255,0.18), rgba(0,0,0,0.45)) border-box;
|
||||
color: var(--muted);
|
||||
border: 1px solid transparent;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease, color 120ms ease;
|
||||
line-height: 0;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
#downloads-btn:hover { filter: brightness(1.05); box-shadow: 0 4px 14px rgba(0,0,0,0.35); color: var(--text); }
|
||||
#downloads-btn:active { transform: translateY(1px) scale(0.98); }
|
||||
#downloads-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(123, 97, 255, 0.55); }
|
||||
#downloads-btn:focus { outline: none; box-shadow: none; }
|
||||
|
||||
/* Match home-active chrome variant */
|
||||
body:has(#home-container.active) #downloads-btn {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(60,66,90,0.98), rgba(44,48,68,0.98)) padding-box,
|
||||
linear-gradient(180deg, rgba(255,255,255,0.22), rgba(0,0,0,0.5)) border-box;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
|
||||
}
|
||||
|
||||
.nav-left button:hover,
|
||||
@@ -267,6 +305,54 @@ html, body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Downloads mini popup anchored to the downloads button */
|
||||
.downloads-wrapper { position: relative; z-index: 10002; }
|
||||
#downloads-popup {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30,34,50,0.95), rgba(25,28,42,0.95)) padding-box,
|
||||
linear-gradient(135deg, rgba(140, 86, 255, 0.18), rgba(62, 149, 255, 0.14)) border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
min-width: 280px;
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: 8px;
|
||||
-webkit-backdrop-filter: blur(var(--blur));
|
||||
backdrop-filter: blur(var(--blur));
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
#downloads-popup.hidden { opacity: 0; transform: translateY(-6px); visibility: hidden; pointer-events: none; }
|
||||
.downloads-pop-header { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:4px 2px 8px; }
|
||||
.downloads-pop-header > span { font-weight: 600; color: var(--text); }
|
||||
.downloads-pop-header > button { background: transparent; border: none; color: var(--accent); cursor: pointer; }
|
||||
.downloads-pop-list { display:flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; }
|
||||
.downloads-empty { color: #aaa; font-size: 12px; text-align: center; padding: 16px 8px; }
|
||||
.dl-item { display:grid; grid-template-columns: 1fr auto; gap: 6px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 8px; }
|
||||
.dl-file { font-size: 12px; color: #eee; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
||||
.dl-meta { font-size: 11px; color: #bbb; }
|
||||
.dl-actions { display:flex; gap:6px; }
|
||||
.dl-actions button { background: transparent; color: #ddd; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 4px 8px; cursor: pointer; }
|
||||
.dl-progress { height: 4px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; grid-column: 1 / -1; }
|
||||
.dl-bar { height: 100%; background: #3b82f6; width: 0%; transition: width .12s linear; }
|
||||
|
||||
/* Circular progress ring around downloads button */
|
||||
#downloads-btn { position: relative; }
|
||||
#downloads-btn .ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 40px; /* slightly larger than 34px button for halo */
|
||||
height: 40px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
#downloads-btn .ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
#downloads-btn .ring circle.bg { stroke: rgba(255,255,255,0.15); stroke-width: 3; fill: none; }
|
||||
#downloads-btn .ring circle.fg { stroke: #3b82f6; stroke-width: 3; fill: none; stroke-linecap: round; transition: stroke-dashoffset .12s linear, opacity .12s ease; }
|
||||
|
||||
/* WEBVIEWS */
|
||||
#webviews {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
[
|
||||
"https://www.google.com/search?q=Andrew%20Zambazos",
|
||||
"https://nebula.zambazosmedia.group/",
|
||||
"https://www.google.com/search?q=transparent%20imges&sei=Vxa-aKjEILaVg8UPxfWfuA0",
|
||||
"https://pngtree.com/free-png",
|
||||
"https://www.rawpixel.com/search/transparent%20png?page=1&sort=curated",
|
||||
"https://www.rawpixel.com/image/6329319/png-sticker-public-domain",
|
||||
"https://www.rawpixel.com/image/6329319/png-sticker-public-domain#eyJrZXlzIjoidHJhbnNwYXJlbnQgcG5nIiwic29ydGVkS2V5cyI6InBuZyB0cmFuc3BhcmVudCJ9",
|
||||
"https://www.rawpixel.com/search/transparent%20png",
|
||||
"https://www.google.com/search?q=transparent%20imges",
|
||||
"https://www.youtube.com/",
|
||||
"https://www.youtube.com/?themeRefresh=1",
|
||||
"https://github.com/sessions/two-factor/webauthn",
|
||||
|
||||
Reference in New Issue
Block a user