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:
2025-09-08 12:31:01 +12:00
parent 311340bd6d
commit 37c1f98261
7 changed files with 573 additions and 3 deletions
+111
View File
@@ -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
View File
@@ -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
View File
@@ -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';
}
+86
View File
@@ -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;