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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user