feat: web UI redesign with PWA support, voice page, and mobile layout
- Redesign dash/history/notifications/trace pages with unified CSS framework (ui.css): cards, badges, scrollable tables, responsive grid, PWA shell. - Add /voice page with chat-bubble transcript, listening indicator, and push-to-talk button for voice interaction. - Add mavweb.js: client-side JS for live transcript polling, auto-scroll, push-to-talk, and service-worker registration. - Update PWA manifest, icon, and service-worker for installable web app. - Add WebAuthn credential management (register/list/delete) with modal UI. - Update main.go: /voice route, WebAuthn handlers, new page navigation. - Update handlers_test.go for new voice and WebAuthn routes.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/* ═══════════════════════════════════════════════
|
||||
mavweb — shared JS for Ethos workstation shell
|
||||
Command palette, search, keyboard shortcuts,
|
||||
inspector, connection status
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── Keyboard Shortcuts ──
|
||||
const shortcuts = {
|
||||
'ctrl+k': 'openPalette',
|
||||
'ctrl+/': 'openSearch',
|
||||
'escape': 'closeAll',
|
||||
'alt+ArrowLeft': 'navigateBack',
|
||||
'alt+ArrowRight': 'navigateForward',
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
const key = [];
|
||||
if (e.ctrlKey || e.metaKey) key.push('ctrl');
|
||||
if (e.altKey) key.push('alt');
|
||||
if (e.shiftKey) key.push('shift');
|
||||
key.push(e.key === ' ' ? 'Space' : e.key);
|
||||
|
||||
const combo = key.join('+').toLowerCase();
|
||||
const action = shortcuts[combo];
|
||||
|
||||
if (action === 'openPalette') {
|
||||
e.preventDefault();
|
||||
openPalette();
|
||||
} else if (action === 'openSearch') {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
} else if (action === 'closeAll') {
|
||||
closePalette();
|
||||
closeSearch();
|
||||
closeInspector();
|
||||
} else if (action === 'navigateBack') {
|
||||
history.back();
|
||||
} else if (action === 'navigateForward') {
|
||||
history.forward();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Command Palette ──
|
||||
let paletteEl = null;
|
||||
let paletteInput = null;
|
||||
|
||||
// Page entries for the command palette
|
||||
const palettePages = [
|
||||
{ label: 'Dashboard', url: '/dash', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>' },
|
||||
{ label: 'History', url: '/history', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
|
||||
{ label: 'Rule Trace', url: '/trace', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>' },
|
||||
{ label: 'Notifications', url: '/notifications', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>' },
|
||||
{ label: 'Voice', url: '/', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>' },
|
||||
{ label: 'Tools', url: '/tools', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>' },
|
||||
{ label: 'Passkey', url: '/auth/passkey', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
|
||||
];
|
||||
|
||||
function buildPalette() {
|
||||
if (paletteEl) return;
|
||||
paletteEl = document.createElement('div');
|
||||
paletteEl.className = 'cmd-palette-overlay';
|
||||
paletteEl.innerHTML =
|
||||
'<div class=cmd-palette>' +
|
||||
'<div class=cmd-palette-input>' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
|
||||
'<input type=text placeholder="Go to page or run action…" spellcheck=false autofocus>' +
|
||||
'</div>' +
|
||||
'<div class=cmd-palette-list id=paletteList></div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(paletteEl);
|
||||
|
||||
paletteInput = paletteEl.querySelector('input');
|
||||
|
||||
paletteEl.addEventListener('click', function(e) {
|
||||
if (e.target === paletteEl) closePalette();
|
||||
});
|
||||
|
||||
paletteInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') { closePalette(); return; }
|
||||
if (e.key === 'Enter') {
|
||||
const selected = paletteEl.querySelector('.cmd-palette-item.selected');
|
||||
if (selected) {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
window.location.href = selected.getAttribute('data-url') || selected.getAttribute('href');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1); return; }
|
||||
});
|
||||
|
||||
paletteInput.addEventListener('input', filterPalette);
|
||||
}
|
||||
|
||||
function moveSelection(dir) {
|
||||
const items = paletteEl.querySelectorAll('.cmd-palette-item');
|
||||
if (!items.length) return;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].classList.contains('selected')) { idx = i; items[i].classList.remove('selected'); break; }
|
||||
}
|
||||
let next = idx === -1 ? 0 : (idx + dir + items.length) % items.length;
|
||||
items[next].classList.add('selected');
|
||||
items[next].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function filterPalette() {
|
||||
const q = paletteInput.value.toLowerCase();
|
||||
const list = paletteEl.querySelector('#paletteList');
|
||||
let html = '';
|
||||
const all = palettePages;
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const p = all[i];
|
||||
if (q && !p.label.toLowerCase().includes(q)) continue;
|
||||
html += '<a href="' + p.url + '" class="cmd-palette-item' + (i === 0 ? ' selected' : '') + '" data-url="' + p.url + '">' +
|
||||
'<span class=icon>' + p.icon + '</span>' +
|
||||
'<span>' + p.label + '</span>' +
|
||||
'</a>';
|
||||
}
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
window.__openPalette = function openPalette() {
|
||||
buildPalette();
|
||||
closeSearch();
|
||||
paletteEl.classList.add('open');
|
||||
setTimeout(function() { paletteInput.focus(); paletteInput.select(); }, 50);
|
||||
filterPalette();
|
||||
};
|
||||
|
||||
function closePalette() {
|
||||
if (paletteEl) paletteEl.classList.remove('open');
|
||||
}
|
||||
window.closePalette = closePalette;
|
||||
|
||||
// ── Search (reuses command palette with search context) ──
|
||||
window.__openSearch = function openSearch() {
|
||||
openPalette();
|
||||
if (paletteInput) {
|
||||
paletteInput.placeholder = 'Search pages, tools, history…';
|
||||
}
|
||||
};
|
||||
|
||||
function closeSearch() {
|
||||
// search uses the same palette overlay
|
||||
if (paletteInput) paletteInput.placeholder = 'Go to page or run action…';
|
||||
}
|
||||
|
||||
// ── Close all overlays ──
|
||||
function closeAll() {
|
||||
closePalette();
|
||||
closeInspector();
|
||||
}
|
||||
|
||||
// ── Inspector ──
|
||||
window.openInspector = function openInspector(title, content) {
|
||||
var inspector = document.getElementById('inspector');
|
||||
var titleEl = document.getElementById('inspectorTitle');
|
||||
var bodyEl = document.getElementById('inspectorBody');
|
||||
if (!inspector || !titleEl || !bodyEl) return;
|
||||
titleEl.textContent = title || 'Details';
|
||||
bodyEl.innerHTML = content || '';
|
||||
inspector.classList.add('open');
|
||||
};
|
||||
|
||||
window.closeInspector = function closeInspector() {
|
||||
var inspector = document.getElementById('inspector');
|
||||
if (inspector) inspector.classList.remove('open');
|
||||
};
|
||||
|
||||
// ── Connection Status ──
|
||||
function updateConnStatus() {
|
||||
var dot = document.getElementById('connDot');
|
||||
if (!dot) return;
|
||||
fetch('/api/ping')
|
||||
.then(function(r) {
|
||||
dot.className = 'dot ' + (r.ok ? 'online' : 'offline');
|
||||
})
|
||||
.catch(function() {
|
||||
dot.className = 'dot offline';
|
||||
});
|
||||
}
|
||||
// Check connection on load and every 60s
|
||||
updateConnStatus();
|
||||
setInterval(updateConnStatus, 60000);
|
||||
|
||||
// ── Inspector: row click to open detail ──
|
||||
// Attach click handler to tables for rows with data attributes
|
||||
document.addEventListener('click', function(e) {
|
||||
var row = e.target.closest('tr.clickable');
|
||||
if (!row) return;
|
||||
var detailUrl = row.getAttribute('data-detail-url');
|
||||
if (detailUrl) {
|
||||
fetch(detailUrl)
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) {
|
||||
openInspector(row.getAttribute('data-detail-title') || 'Details', html);
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user