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
+203 -1
View File
@@ -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 { pathToFileURL } = require('url');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@@ -317,6 +317,16 @@ app.whenReady().then(() => {
// Defer session configuration to microtask/next tick (already inexpensive) keep explicit // Defer session configuration to microtask/next tick (already inexpensive) keep explicit
setImmediate(configureSessionsAsync); 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') { if (process.platform === 'darwin') {
app.dock.setIcon(path.join(__dirname, 'assets/images/Logos/Nebula-Icon.icns')); app.dock.setIcon(path.join(__dirname, 'assets/images/Logos/Nebula-Icon.icns'));
} }
@@ -669,6 +679,10 @@ function buildAndShowContextMenu(sender, params = {}) {
if (linkURL) { if (linkURL) {
template.push( template.push(
{ label: 'Open Link in New Tab', click: () => embedder.send('context-menu-command', { cmd: 'open-link-new-tab', url: linkURL }) }, { 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: 'Open Link Externally', click: () => shell.openExternal(linkURL).catch(()=>{}) },
{ label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) }, { label: 'Copy Link Address', click: () => clipboard.writeText(linkURL) },
{ type: 'separator' } { type: 'separator' }
@@ -811,3 +825,191 @@ ipcMain.handle('save-image-from-url', async (event, { url }) => {
return false; 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
View File
@@ -120,3 +120,14 @@ contextBridge.exposeInMainWorld('aboutAPI', {
ipcRenderer.on('context-menu-command', (event, payload) => { ipcRenderer.on('context-menu-command', (event, payload) => {
window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: 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)
});
+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>
<div class="nav-right"> <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"> <div class="menu-wrapper">
<button id="menu-btn"></button> <button id="menu-btn"></button>
<div id="menu-popup" class="hidden"> <div id="menu-popup" class="hidden">
+136 -1
View File
@@ -52,7 +52,7 @@ urlBox.addEventListener('keydown', (e) => {
let tabs = []; let tabs = [];
let activeTabId = null; let activeTabId = null;
const allowedInternalPages = ['settings', 'home']; const allowedInternalPages = ['settings', 'home', 'downloads'];
let bookmarks = []; let bookmarks = [];
// Efficient render scheduling to avoid redundant DOM work // Efficient render scheduling to avoid redundant DOM work
@@ -130,6 +130,8 @@ ipcRenderer.on('record-site-history', (event, url) => {
addToSiteHistory(url); addToSiteHistory(url);
}); });
// Auto-open on download start is disabled by design now.
function createTab(inputUrl) { function createTab(inputUrl) {
inputUrl = inputUrl || 'browser://home'; inputUrl = inputUrl || 'browser://home';
debug('[DEBUG] createTab() inputUrl =', inputUrl); debug('[DEBUG] createTab() inputUrl =', inputUrl);
@@ -790,9 +792,21 @@ function openSettings() {
createTab('browser://settings'); createTab('browser://settings');
} }
// Open Downloads manager page
function openDownloads() {
createTab('browser://downloads');
}
// Toggle menu dropdown // Toggle menu dropdown
const menuBtn = document.getElementById('menu-btn'); const menuBtn = document.getElementById('menu-btn');
const menuWrapper = document.querySelector('.menu-wrapper'); 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 // Open/close on button click; stop propagation so outside-click handler doesn't immediately close it
menuBtn.addEventListener('click', (e) => { menuBtn.addEventListener('click', (e) => {
@@ -821,6 +835,9 @@ document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menuPopup && !menuPopup.classList.contains('hidden')) { if (e.key === 'Escape' && menuPopup && !menuPopup.classList.contains('hidden')) {
menuPopup.classList.add('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) // 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'); const settingsBtn = document.getElementById('open-settings-btn');
if (settingsBtn) settingsBtn.addEventListener('click', openSettings); 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 // window control bindings
const minBtn = document.getElementById('min-btn'); const minBtn = document.getElementById('min-btn');
const maxBtn = document.getElementById('max-btn'); const maxBtn = document.getElementById('max-btn');
@@ -995,6 +1046,9 @@ function attachCloseMenuOnInteract(el) {
if (menuPopup && !menuPopup.classList.contains('hidden')) { if (menuPopup && !menuPopup.classList.contains('hidden')) {
menuPopup.classList.add('hidden'); menuPopup.classList.add('hidden');
} }
if (downloadsPopupEl && !downloadsPopupEl.classList.contains('hidden')) {
hideDownloadsPopup();
}
}; };
el.addEventListener('mousedown', closeIfOpen); el.addEventListener('mousedown', closeIfOpen);
el.addEventListener('pointerdown', closeIfOpen); el.addEventListener('pointerdown', closeIfOpen);
@@ -1059,3 +1113,84 @@ window.addEventListener('nebula-context-command', (e) => {
break; 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 */ /* subtle inner highlight adds edge definition */
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease; 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, .nav-left button:hover,
@@ -267,6 +305,54 @@ html, body {
pointer-events: none; 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 */
#webviews { #webviews {
flex: 1; flex: 1;
+9
View File
@@ -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/",
"https://www.youtube.com/?themeRefresh=1", "https://www.youtube.com/?themeRefresh=1",
"https://github.com/sessions/two-factor/webauthn", "https://github.com/sessions/two-factor/webauthn",