mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
feat(ui): UI overhaul — consolidates #305-#309 (keyboard shortcuts, perf monitor, toasts, theme, command palette, activity log, data export, mobile PWA, accessibility, i18n) (#620)
* feat(ui): add keyboard shortcuts, perf monitor, toast system, theme toggle, and WCAG accessibility - Keyboard shortcuts overlay (press ? for help, 1-8 for tabs, T for theme, P for perf) - Real-time performance monitor with FPS, memory, latency sparklines (draggable) - Enhanced toast notification system with stacking, auto-dismiss, progress bars - Dark/light theme toggle with localStorage persistence and system preference detection - WCAG accessibility: skip-to-content link, ARIA roles/attributes on tabs and panels, arrow key navigation in tab bar, focus-visible outlines - ESLint config for UI directory with security and quality rules * feat(ui): add command palette, activity log, data export, fullscreen mode, connection status - Command palette (Ctrl+K / Cmd+K) with fuzzy search across tabs and actions - Activity log panel (L key) with real-time console interception, filters, resizable - Data export utility (E key) for sensor data as JSON/CSV with dialog - Fullscreen mode (F key / F11) for visualization tabs with exit button - Connection status widget in header showing WebSocket state and reconnect * feat(ui): add mobile hamburger nav, PWA support, and 40 unit tests - Mobile hamburger navigation: slide-out drawer replacing tab bar on <768px, swipe-to-close, animated hamburger icon, auto-sync with tab manager - PWA manifest + service worker: installable dashboard, offline shell caching (cache-first for static, network-first for API), auto-cleanup of old caches - 40 unit tests for ToastManager, ThemeToggle, KeyboardShortcuts, PerfMonitor, TabManager - browser-based test runner at ui/tests/unit-tests.html - PWA meta tags: theme-color, apple-mobile-web-app-capable, manifest link - Icon generator page for creating PWA icons (ui/icons/generate.html) * feat(ui): add URL routing, onboarding tour, idle detection, notification center - Hash router: tabs are bookmarkable/shareable via URL (#demo, #sensing, etc.), syncs with TabManager, supports browser back/forward navigation - Onboarding tour: interactive 6-step first-run walkthrough with spotlight highlighting, step indicators, skip/back/next controls, localStorage persistence - Idle detection: pauses health polling and reduces CSS animations after 3 min of inactivity, resumes on user interaction, integrates with Page Visibility API - Notification center: bell icon in header with unread badge, event history panel with mark-read/clear, persists across page views via sessionStorage * feat(ui): add i18n (EN/PL), screenshot tool, settings panel, reduced motion, uptime clock - i18n: English/Polish translations with auto-detection, language selector in header, data-i18n attributes on dashboard elements, localStorage persistence - Screenshot tool (S key): captures active tab to clipboard or downloads PNG, flash effect, canvas rendering with watermark, fallback for tainted canvases - Quick settings panel (gear icon): reduced motion toggle, high contrast mode, compact layout mode, health polling toggle, clear data, reset onboarding - Uptime clock: current time + session duration in header - prefers-reduced-motion: system-level and manual toggle, disables all animations and transitions for vestibular accessibility - High contrast mode: WCAG AAA compliant colors for both light and dark themes - Compact mode: condensed layout for dense information display
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// Activity Log - Scrollable panel showing system events in real-time
|
||||
// Toggle with 'L' key or command palette
|
||||
|
||||
export class ActivityLog {
|
||||
constructor() {
|
||||
this.panel = null;
|
||||
this.visible = false;
|
||||
this.entries = [];
|
||||
this.maxEntries = 200;
|
||||
this.logBody = null;
|
||||
this.filters = { info: true, warning: true, error: true, connection: true };
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPanel();
|
||||
this.interceptConsole();
|
||||
document.addEventListener('toggle-activity-log', () => this.toggle());
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'activity-log';
|
||||
this.panel.setAttribute('role', 'log');
|
||||
this.panel.setAttribute('aria-label', 'Activity log');
|
||||
this.panel.innerHTML = `
|
||||
<div class="activity-log-header">
|
||||
<span class="activity-log-title">Activity Log</span>
|
||||
<div class="activity-log-controls">
|
||||
<button class="activity-log-filter active" data-filter="info" aria-label="Toggle info messages" title="Info">I</button>
|
||||
<button class="activity-log-filter active" data-filter="warning" aria-label="Toggle warnings" title="Warnings">W</button>
|
||||
<button class="activity-log-filter active" data-filter="error" aria-label="Toggle errors" title="Errors">E</button>
|
||||
<button class="activity-log-filter active" data-filter="connection" aria-label="Toggle connection events" title="Connection">C</button>
|
||||
<button class="activity-log-clear" aria-label="Clear log" title="Clear">Clear</button>
|
||||
<button class="activity-log-close" aria-label="Close activity log">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-log-body"></div>
|
||||
`;
|
||||
|
||||
this.logBody = this.panel.querySelector('.activity-log-body');
|
||||
|
||||
// Filter toggles
|
||||
this.panel.querySelectorAll('.activity-log-filter').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const filter = btn.dataset.filter;
|
||||
this.filters[filter] = !this.filters[filter];
|
||||
btn.classList.toggle('active', this.filters[filter]);
|
||||
this.rerender();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear button
|
||||
this.panel.querySelector('.activity-log-clear').addEventListener('click', () => {
|
||||
this.entries = [];
|
||||
this.rerender();
|
||||
});
|
||||
|
||||
// Close button
|
||||
this.panel.querySelector('.activity-log-close').addEventListener('click', () => this.hide());
|
||||
|
||||
// Make resizable by dragging top edge
|
||||
this.makeResizable();
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
}
|
||||
|
||||
makeResizable() {
|
||||
let resizing = false;
|
||||
let startY = 0;
|
||||
let startHeight = 0;
|
||||
|
||||
this.panel.addEventListener('mousedown', (e) => {
|
||||
// Only top 5px edge
|
||||
const rect = this.panel.getBoundingClientRect();
|
||||
if (e.clientY - rect.top > 5) return;
|
||||
resizing = true;
|
||||
startY = e.clientY;
|
||||
startHeight = rect.height;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!resizing) return;
|
||||
const delta = startY - e.clientY;
|
||||
const newHeight = Math.max(150, Math.min(window.innerHeight * 0.7, startHeight + delta));
|
||||
this.panel.style.height = `${newHeight}px`;
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => { resizing = false; });
|
||||
}
|
||||
|
||||
interceptConsole() {
|
||||
const origInfo = console.info;
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
|
||||
console.info = (...args) => {
|
||||
origInfo.apply(console, args);
|
||||
this.addEntry('info', args.map(String).join(' '));
|
||||
};
|
||||
|
||||
console.warn = (...args) => {
|
||||
origWarn.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
const type = msg.includes('[WS-') || msg.includes('connect') ? 'connection' : 'warning';
|
||||
this.addEntry(type, msg);
|
||||
};
|
||||
|
||||
console.error = (...args) => {
|
||||
origError.apply(console, args);
|
||||
this.addEntry('error', args.map(String).join(' '));
|
||||
};
|
||||
}
|
||||
|
||||
addEntry(type, message) {
|
||||
const entry = {
|
||||
time: new Date(),
|
||||
type,
|
||||
message: this.truncate(message, 300)
|
||||
};
|
||||
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > this.maxEntries) {
|
||||
this.entries.shift();
|
||||
}
|
||||
|
||||
if (this.visible && this.filters[type]) {
|
||||
this.appendEntry(entry);
|
||||
// Auto-scroll to bottom
|
||||
this.logBody.scrollTop = this.logBody.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
appendEntry(entry) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `activity-log-entry activity-log-${entry.type}`;
|
||||
const time = entry.time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
el.innerHTML = `<span class="activity-log-time">${time}</span><span class="activity-log-type">${entry.type.toUpperCase().charAt(0)}</span><span class="activity-log-msg">${this.escapeHtml(entry.message)}</span>`;
|
||||
this.logBody.appendChild(el);
|
||||
}
|
||||
|
||||
rerender() {
|
||||
this.logBody.innerHTML = '';
|
||||
this.entries
|
||||
.filter(e => this.filters[e.type])
|
||||
.forEach(e => this.appendEntry(e));
|
||||
this.logBody.scrollTop = this.logBody.scrollHeight;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true;
|
||||
this.panel.classList.add('visible');
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false;
|
||||
this.panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.hide();
|
||||
if (this.panel?.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Command Palette - Ctrl+K / Cmd+K to search and execute commands
|
||||
// Fuzzy search across tabs, actions, and settings
|
||||
|
||||
export class CommandPalette {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.overlay = null;
|
||||
this.input = null;
|
||||
this.results = null;
|
||||
this.visible = false;
|
||||
this.commands = [];
|
||||
this.selectedIndex = 0;
|
||||
this.filteredCommands = [];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.registerCommands();
|
||||
this.createDOM();
|
||||
this.bindGlobalShortcut();
|
||||
}
|
||||
|
||||
registerCommands() {
|
||||
// Navigation commands
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: 'grid' },
|
||||
{ id: 'hardware', label: 'Hardware', icon: 'cpu' },
|
||||
{ id: 'demo', label: 'Live Demo', icon: 'play' },
|
||||
{ id: 'architecture', label: 'Architecture', icon: 'layers' },
|
||||
{ id: 'performance', label: 'Performance', icon: 'zap' },
|
||||
{ id: 'applications', label: 'Applications', icon: 'box' },
|
||||
{ id: 'sensing', label: 'Sensing', icon: 'wifi' },
|
||||
{ id: 'training', label: 'Training', icon: 'database' },
|
||||
];
|
||||
|
||||
tabs.forEach(tab => {
|
||||
this.commands.push({
|
||||
category: 'Navigation',
|
||||
label: `Go to ${tab.label}`,
|
||||
keywords: [tab.id, tab.label.toLowerCase()],
|
||||
icon: tab.icon,
|
||||
action: () => {
|
||||
const tm = this.app?.getComponent?.('tabManager');
|
||||
if (tm) tm.switchToTab(tab.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// External pages
|
||||
this.commands.push({
|
||||
category: 'Navigation',
|
||||
label: 'Open Pose Fusion',
|
||||
keywords: ['pose', 'fusion', 'camera'],
|
||||
icon: 'external',
|
||||
action: () => { window.location.href = 'pose-fusion.html'; }
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Navigation',
|
||||
label: 'Open Observatory',
|
||||
keywords: ['observatory', '3d', 'signal'],
|
||||
icon: 'external',
|
||||
action: () => { window.location.href = 'observatory.html'; }
|
||||
});
|
||||
|
||||
// Actions
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Dark/Light Theme',
|
||||
keywords: ['theme', 'dark', 'light', 'mode', 'color'],
|
||||
icon: 'moon',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-theme'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Performance Monitor',
|
||||
keywords: ['perf', 'fps', 'memory', 'performance', 'monitor'],
|
||||
icon: 'activity',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-perf-monitor'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Activity Log',
|
||||
keywords: ['log', 'events', 'activity', 'history'],
|
||||
icon: 'list',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-activity-log'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Export Sensor Data',
|
||||
keywords: ['export', 'download', 'csv', 'json', 'data', 'save'],
|
||||
icon: 'download',
|
||||
action: () => document.dispatchEvent(new CustomEvent('export-data'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Toggle Fullscreen',
|
||||
keywords: ['fullscreen', 'full', 'screen', 'maximize'],
|
||||
icon: 'maximize',
|
||||
action: () => document.dispatchEvent(new CustomEvent('toggle-fullscreen'))
|
||||
});
|
||||
this.commands.push({
|
||||
category: 'Actions',
|
||||
label: 'Show Keyboard Shortcuts',
|
||||
keywords: ['keyboard', 'shortcuts', 'keys', 'help'],
|
||||
icon: 'keyboard',
|
||||
action: () => document.dispatchEvent(new CustomEvent('show-shortcuts'))
|
||||
});
|
||||
}
|
||||
|
||||
createDOM() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'cmd-palette-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Command palette');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
|
||||
this.overlay.innerHTML = `
|
||||
<div class="cmd-palette">
|
||||
<div class="cmd-palette-input-wrap">
|
||||
<svg class="cmd-palette-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" class="cmd-palette-input" placeholder="Type a command..." aria-label="Search commands" autocomplete="off" spellcheck="false">
|
||||
<kbd class="cmd-palette-hint">Esc</kbd>
|
||||
</div>
|
||||
<div class="cmd-palette-results" role="listbox" aria-label="Commands"></div>
|
||||
<div class="cmd-palette-footer">
|
||||
<span><kbd>Up</kbd><kbd>Down</kbd> navigate</span>
|
||||
<span><kbd>Enter</kbd> execute</span>
|
||||
<span><kbd>Esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.hide();
|
||||
});
|
||||
|
||||
this.input = this.overlay.querySelector('.cmd-palette-input');
|
||||
this.results = this.overlay.querySelector('.cmd-palette-results');
|
||||
|
||||
this.input.addEventListener('input', () => this.onInput());
|
||||
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
|
||||
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
bindGlobalShortcut() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+K or Cmd+K
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true;
|
||||
this.overlay.classList.add('visible');
|
||||
this.input.value = '';
|
||||
this.selectedIndex = 0;
|
||||
this.filteredCommands = [...this.commands];
|
||||
this.renderResults();
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false;
|
||||
this.overlay.classList.remove('visible');
|
||||
}
|
||||
|
||||
onInput() {
|
||||
const query = this.input.value.toLowerCase().trim();
|
||||
if (!query) {
|
||||
this.filteredCommands = [...this.commands];
|
||||
} else {
|
||||
this.filteredCommands = this.commands
|
||||
.map(cmd => {
|
||||
const score = this.fuzzyScore(query, cmd);
|
||||
return { ...cmd, score };
|
||||
})
|
||||
.filter(cmd => cmd.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
this.selectedIndex = 0;
|
||||
this.renderResults();
|
||||
}
|
||||
|
||||
fuzzyScore(query, cmd) {
|
||||
const targets = [cmd.label.toLowerCase(), ...cmd.keywords, cmd.category.toLowerCase()];
|
||||
let best = 0;
|
||||
for (const target of targets) {
|
||||
if (target === query) return 100;
|
||||
if (target.startsWith(query)) best = Math.max(best, 80);
|
||||
if (target.includes(query)) best = Math.max(best, 60);
|
||||
// Check each word
|
||||
const words = query.split(/\s+/);
|
||||
const allMatch = words.every(w => targets.some(t => t.includes(w)));
|
||||
if (allMatch) best = Math.max(best, 40);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
if (this.filteredCommands.length === 0) {
|
||||
this.results.innerHTML = '<div class="cmd-palette-empty">No matching commands</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let lastCategory = '';
|
||||
let html = '';
|
||||
|
||||
this.filteredCommands.forEach((cmd, i) => {
|
||||
if (cmd.category !== lastCategory) {
|
||||
lastCategory = cmd.category;
|
||||
html += `<div class="cmd-palette-category">${cmd.category}</div>`;
|
||||
}
|
||||
const selected = i === this.selectedIndex ? ' cmd-palette-item-selected' : '';
|
||||
html += `
|
||||
<div class="cmd-palette-item${selected}" data-index="${i}" role="option" aria-selected="${i === this.selectedIndex}">
|
||||
<span class="cmd-palette-item-icon">${this.getIcon(cmd.icon)}</span>
|
||||
<span class="cmd-palette-item-label">${cmd.label}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
this.results.innerHTML = html;
|
||||
|
||||
// Click handlers
|
||||
this.results.querySelectorAll('.cmd-palette-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const idx = parseInt(el.dataset.index, 10);
|
||||
this.executeCommand(idx);
|
||||
});
|
||||
el.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = parseInt(el.dataset.index, 10);
|
||||
this.updateSelection();
|
||||
});
|
||||
});
|
||||
|
||||
// Scroll selected into view
|
||||
const selectedEl = this.results.querySelector('.cmd-palette-item-selected');
|
||||
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
this.results.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
||||
const isSelected = parseInt(el.dataset.index, 10) === this.selectedIndex;
|
||||
el.classList.toggle('cmd-palette-item-selected', isSelected);
|
||||
el.setAttribute('aria-selected', String(isSelected));
|
||||
});
|
||||
}
|
||||
|
||||
onKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
|
||||
this.updateSelection();
|
||||
const sel = this.results.querySelector('.cmd-palette-item-selected');
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.updateSelection();
|
||||
const sel = this.results.querySelector('.cmd-palette-item-selected');
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeCommand(this.selectedIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
executeCommand(index) {
|
||||
const cmd = this.filteredCommands[index];
|
||||
if (cmd) {
|
||||
this.hide();
|
||||
cmd.action();
|
||||
}
|
||||
}
|
||||
|
||||
getIcon(name) {
|
||||
const icons = {
|
||||
grid: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
|
||||
cpu: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/></svg>',
|
||||
play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
||||
layers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
|
||||
zap: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
box: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>',
|
||||
wifi: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>',
|
||||
database: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
|
||||
external: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
|
||||
moon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
|
||||
activity: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
|
||||
list: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
|
||||
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
||||
maximize: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
|
||||
keyboard: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="6.01" y2="8"/><line x1="10" y1="8" x2="10.01" y2="8"/><line x1="14" y1="8" x2="14.01" y2="8"/><line x1="18" y1="8" x2="18.01" y2="8"/><line x1="8" y1="12" x2="8.01" y2="12"/><line x1="12" y1="12" x2="12.01" y2="12"/><line x1="16" y1="12" x2="16.01" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></svg>'
|
||||
};
|
||||
return icons[name] || '';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Connection Status Widget - Persistent indicator in header
|
||||
// Shows WebSocket and API connection state with reconnect button
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
export class ConnectionStatus {
|
||||
constructor() {
|
||||
this.widget = null;
|
||||
this._unsub = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createWidget();
|
||||
this.subscribe();
|
||||
}
|
||||
|
||||
createWidget() {
|
||||
this.widget = document.createElement('div');
|
||||
this.widget.className = 'conn-status';
|
||||
this.widget.setAttribute('role', 'status');
|
||||
this.widget.setAttribute('aria-live', 'polite');
|
||||
this.widget.innerHTML = `
|
||||
<span class="conn-status-dot"></span>
|
||||
<span class="conn-status-label">Connecting</span>
|
||||
<button class="conn-status-reconnect" aria-label="Reconnect" title="Reconnect" style="display:none">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
|
||||
this.setStatus('reconnecting', 'Reconnecting...');
|
||||
sensingService.reconnect?.();
|
||||
});
|
||||
|
||||
// Insert into header-info, after theme toggle if present
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.widget);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
this._unsub = sensingService.onStateChange(() => {
|
||||
this.update();
|
||||
});
|
||||
// Initial
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
const state = sensingService.state;
|
||||
const source = sensingService.dataSource;
|
||||
|
||||
if (state === 'connected' || state === 'streaming') {
|
||||
const label = source === 'live' ? 'Live' :
|
||||
source === 'server-simulated' ? 'Simulated' :
|
||||
'Connected';
|
||||
this.setStatus('connected', label);
|
||||
} else if (state === 'connecting' || state === 'reconnecting') {
|
||||
this.setStatus('reconnecting', 'Connecting...');
|
||||
} else if (state === 'error') {
|
||||
this.setStatus('error', 'Error');
|
||||
} else {
|
||||
this.setStatus('disconnected', 'Offline');
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(status, label) {
|
||||
if (!this.widget) return;
|
||||
this.widget.className = `conn-status conn-status-${status}`;
|
||||
this.widget.querySelector('.conn-status-label').textContent = label;
|
||||
|
||||
const reconnectBtn = this.widget.querySelector('.conn-status-reconnect');
|
||||
reconnectBtn.style.display =
|
||||
(status === 'disconnected' || status === 'error') ? '' : 'none';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._unsub) this._unsub();
|
||||
if (this.widget?.parentNode) {
|
||||
this.widget.parentNode.removeChild(this.widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Data Export Utility - Export sensor/pose data as JSON or CSV
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
import { toastManager } from './toast.js';
|
||||
|
||||
export class DataExport {
|
||||
constructor() {
|
||||
this.buffer = [];
|
||||
this.maxBuffer = 1000;
|
||||
this.recording = false;
|
||||
this._unsub = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('export-data', () => this.showExportDialog());
|
||||
|
||||
// Continuously buffer sensing data when available
|
||||
this._unsub = sensingService.onData((data) => {
|
||||
if (this.buffer.length >= this.maxBuffer) {
|
||||
this.buffer.shift();
|
||||
}
|
||||
this.buffer.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
...this.extractFields(data)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
extractFields(data) {
|
||||
// Extract relevant fields from sensing data
|
||||
return {
|
||||
rssi: data.rssi ?? null,
|
||||
variance: data.variance ?? null,
|
||||
motion_band: data.motion_band ?? null,
|
||||
breathing_band: data.breathing_band ?? null,
|
||||
classification: data.classification ?? null,
|
||||
person_count: data.person_count ?? data.persons ?? null,
|
||||
subcarriers: data.subcarrier_count ?? null,
|
||||
source: data.source ?? null
|
||||
};
|
||||
}
|
||||
|
||||
showExportDialog() {
|
||||
if (this.buffer.length === 0) {
|
||||
toastManager.warning('No sensor data to export. Connect to a data source first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dialog
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'export-dialog-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
|
||||
<h3>Export Sensor Data</h3>
|
||||
<p class="export-dialog-info">${this.buffer.length} data points available</p>
|
||||
<div class="export-dialog-options">
|
||||
<label class="export-option">
|
||||
<input type="radio" name="export-format" value="json" checked>
|
||||
<span>JSON</span>
|
||||
<small>Full data with nested fields</small>
|
||||
</label>
|
||||
<label class="export-option">
|
||||
<input type="radio" name="export-format" value="csv">
|
||||
<span>CSV</span>
|
||||
<small>Flat table, spreadsheet-ready</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="export-dialog-range">
|
||||
<label>
|
||||
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
|
||||
</label>
|
||||
</div>
|
||||
<div class="export-dialog-actions">
|
||||
<button class="btn btn--secondary export-cancel">Cancel</button>
|
||||
<button class="btn btn--primary export-confirm">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.export-confirm').addEventListener('click', () => {
|
||||
const format = overlay.querySelector('input[name="export-format"]:checked').value;
|
||||
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
|
||||
this.exportData(format, count);
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('.export-confirm').focus();
|
||||
}
|
||||
|
||||
exportData(format, count) {
|
||||
const data = this.buffer.slice(-count);
|
||||
|
||||
let content, filename, mimeType;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(data, null, 2);
|
||||
filename = `ruview-data-${this.timestamp()}.json`;
|
||||
mimeType = 'application/json';
|
||||
} else {
|
||||
content = this.toCSV(data);
|
||||
filename = `ruview-data-${this.timestamp()}.csv`;
|
||||
mimeType = 'text/csv';
|
||||
}
|
||||
|
||||
this.downloadFile(content, filename, mimeType);
|
||||
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
|
||||
}
|
||||
|
||||
toCSV(data) {
|
||||
if (data.length === 0) return '';
|
||||
const headers = Object.keys(data[0]);
|
||||
const rows = data.map(row => headers.map(h => {
|
||||
const val = row[h];
|
||||
if (val === null || val === undefined) return '';
|
||||
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
|
||||
return `"${val.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return String(val);
|
||||
}).join(','));
|
||||
return [headers.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._unsub) this._unsub();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Fullscreen Mode - Toggle fullscreen on visualization tabs
|
||||
// Activated via F11 key, command palette, or button
|
||||
|
||||
export class FullscreenManager {
|
||||
constructor() {
|
||||
this.isFullscreen = false;
|
||||
this.targetElement = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('toggle-fullscreen', () => this.toggle());
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
this.isFullscreen = !!document.fullscreenElement;
|
||||
this.updateUI();
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isFullscreen) {
|
||||
this.exit();
|
||||
} else {
|
||||
this.enter();
|
||||
}
|
||||
}
|
||||
|
||||
enter() {
|
||||
// Find the active tab content
|
||||
const activePanel = document.querySelector('.tab-content.active');
|
||||
if (!activePanel) return;
|
||||
|
||||
this.targetElement = activePanel;
|
||||
|
||||
if (activePanel.requestFullscreen) {
|
||||
activePanel.requestFullscreen();
|
||||
} else if (activePanel.webkitRequestFullscreen) {
|
||||
activePanel.webkitRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
exit() {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
}
|
||||
this.targetElement = null;
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
document.body.classList.toggle('is-fullscreen', this.isFullscreen);
|
||||
|
||||
// Add/remove exit button when in fullscreen
|
||||
let exitBtn = document.getElementById('fullscreen-exit-btn');
|
||||
if (this.isFullscreen && !exitBtn) {
|
||||
exitBtn = document.createElement('button');
|
||||
exitBtn.id = 'fullscreen-exit-btn';
|
||||
exitBtn.className = 'fullscreen-exit-btn';
|
||||
exitBtn.setAttribute('aria-label', 'Exit fullscreen');
|
||||
exitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
exitBtn.title = 'Exit fullscreen (F11)';
|
||||
exitBtn.addEventListener('click', () => this.exit());
|
||||
document.body.appendChild(exitBtn);
|
||||
} else if (!this.isFullscreen && exitBtn) {
|
||||
exitBtn.remove();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.isFullscreen) this.exit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Internationalization - EN/PL language support
|
||||
// Detects browser language, persists choice, translates UI strings
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.hardware': 'Hardware',
|
||||
'nav.demo': 'Live Demo',
|
||||
'nav.architecture': 'Architecture',
|
||||
'nav.performance': 'Performance',
|
||||
'nav.applications': 'Applications',
|
||||
'nav.sensing': 'Sensing',
|
||||
'nav.training': 'Training',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Revolutionary WiFi-Based Human Pose Detection',
|
||||
'dashboard.subtitle': 'Human Tracking Through Walls Using WiFi Signals',
|
||||
'dashboard.description': 'AI can track your full-body movement through walls using just WiFi signals. Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi signals into detailed wireframe models of human bodies.',
|
||||
'dashboard.status': 'System Status',
|
||||
'dashboard.metrics': 'System Metrics',
|
||||
'dashboard.features': 'Features',
|
||||
'dashboard.liveStats': 'Live Statistics',
|
||||
'dashboard.activePersons': 'Active Persons',
|
||||
'dashboard.avgConfidence': 'Avg Confidence',
|
||||
'dashboard.totalDetections': 'Total Detections',
|
||||
'dashboard.zoneOccupancy': 'Zone Occupancy',
|
||||
|
||||
// Status
|
||||
'status.apiServer': 'API Server',
|
||||
'status.hardware': 'Hardware',
|
||||
'status.inference': 'Inference',
|
||||
'status.streaming': 'Streaming',
|
||||
'status.dataSource': 'Data Source',
|
||||
|
||||
// Metrics
|
||||
'metrics.cpu': 'CPU Usage',
|
||||
'metrics.memory': 'Memory Usage',
|
||||
'metrics.disk': 'Disk Usage',
|
||||
|
||||
// Benefits
|
||||
'benefit.throughWalls': 'Through Walls',
|
||||
'benefit.throughWallsDesc': 'Works through solid barriers with no line of sight required',
|
||||
'benefit.privacy': 'Privacy-Preserving',
|
||||
'benefit.privacyDesc': 'No cameras or visual recording - just WiFi signal analysis',
|
||||
'benefit.realtime': 'Real-Time',
|
||||
'benefit.realtimeDesc': 'Maps 24 body regions in real-time at 100Hz sampling rate',
|
||||
'benefit.lowCost': 'Low Cost',
|
||||
'benefit.lowCostDesc': 'Built using $30 commercial WiFi hardware',
|
||||
|
||||
// Stats
|
||||
'stat.bodyRegions': 'Body Regions',
|
||||
'stat.samplingRate': 'Sampling Rate',
|
||||
'stat.accuracy': 'Accuracy (AP@50)',
|
||||
'stat.hardwareCost': 'Hardware Cost',
|
||||
|
||||
// Actions
|
||||
'action.startDetection': 'Start Detection',
|
||||
'action.stopDetection': 'Stop Detection',
|
||||
'action.toggleTheme': 'Toggle theme',
|
||||
'action.exportData': 'Export data',
|
||||
'action.screenshot': 'Take screenshot',
|
||||
|
||||
// Connection
|
||||
'conn.connected': 'Connected',
|
||||
'conn.connecting': 'Connecting...',
|
||||
'conn.offline': 'Offline',
|
||||
'conn.reconnecting': 'Reconnecting...',
|
||||
'conn.live': 'Live',
|
||||
'conn.simulated': 'Simulated',
|
||||
|
||||
// Misc
|
||||
'misc.loading': 'Loading...',
|
||||
'misc.error': 'An error occurred',
|
||||
'misc.noData': 'No data available',
|
||||
'misc.close': 'Close',
|
||||
'misc.cancel': 'Cancel',
|
||||
'misc.confirm': 'Confirm',
|
||||
'misc.settings': 'Settings',
|
||||
'misc.language': 'Language'
|
||||
},
|
||||
|
||||
pl: {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Panel',
|
||||
'nav.hardware': 'Sprzet',
|
||||
'nav.demo': 'Demo na zywo',
|
||||
'nav.architecture': 'Architektura',
|
||||
'nav.performance': 'Wydajnosc',
|
||||
'nav.applications': 'Aplikacje',
|
||||
'nav.sensing': 'Czujniki',
|
||||
'nav.training': 'Trening',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.title': 'Rewolucyjne wykrywanie pozy czlowieka przez WiFi',
|
||||
'dashboard.subtitle': 'Sledzenie ludzi przez sciany za pomoca sygnalow WiFi',
|
||||
'dashboard.description': 'AI moze sledzic ruchy calego ciala przez sciany uzywajac jedynie sygnalow WiFi. Badacze z Carnegie Mellon wytrenowali siec neuronowa do zamiany sygnalow WiFi w szczegolowe modele szkieletowe.',
|
||||
'dashboard.status': 'Status systemu',
|
||||
'dashboard.metrics': 'Metryki systemu',
|
||||
'dashboard.features': 'Funkcje',
|
||||
'dashboard.liveStats': 'Statystyki na zywo',
|
||||
'dashboard.activePersons': 'Aktywne osoby',
|
||||
'dashboard.avgConfidence': 'Srednia pewnosc',
|
||||
'dashboard.totalDetections': 'Laczne detekcje',
|
||||
'dashboard.zoneOccupancy': 'Zajecie stref',
|
||||
|
||||
// Status
|
||||
'status.apiServer': 'Serwer API',
|
||||
'status.hardware': 'Sprzet',
|
||||
'status.inference': 'Wnioskowanie',
|
||||
'status.streaming': 'Streaming',
|
||||
'status.dataSource': 'Zrodlo danych',
|
||||
|
||||
// Metrics
|
||||
'metrics.cpu': 'Uzycie CPU',
|
||||
'metrics.memory': 'Uzycie pamieci',
|
||||
'metrics.disk': 'Uzycie dysku',
|
||||
|
||||
// Benefits
|
||||
'benefit.throughWalls': 'Przez sciany',
|
||||
'benefit.throughWallsDesc': 'Dziala przez przeszkody stale bez linii wzroku',
|
||||
'benefit.privacy': 'Ochrona prywatnosci',
|
||||
'benefit.privacyDesc': 'Brak kamer i nagrywania - tylko analiza sygnalow WiFi',
|
||||
'benefit.realtime': 'Czas rzeczywisty',
|
||||
'benefit.realtimeDesc': 'Mapuje 24 regiony ciala w czasie rzeczywistym przy 100Hz',
|
||||
'benefit.lowCost': 'Niski koszt',
|
||||
'benefit.lowCostDesc': 'Zbudowany z komercyjnego sprzetu WiFi za $30',
|
||||
|
||||
// Stats
|
||||
'stat.bodyRegions': 'Regiony ciala',
|
||||
'stat.samplingRate': 'Czestotliwosc',
|
||||
'stat.accuracy': 'Dokladnosc (AP@50)',
|
||||
'stat.hardwareCost': 'Koszt sprzetu',
|
||||
|
||||
// Actions
|
||||
'action.startDetection': 'Rozpocznij detekcje',
|
||||
'action.stopDetection': 'Zatrzymaj detekcje',
|
||||
'action.toggleTheme': 'Zmien motyw',
|
||||
'action.exportData': 'Eksportuj dane',
|
||||
'action.screenshot': 'Zrob zrzut ekranu',
|
||||
|
||||
// Connection
|
||||
'conn.connected': 'Polaczono',
|
||||
'conn.connecting': 'Laczenie...',
|
||||
'conn.offline': 'Offline',
|
||||
'conn.reconnecting': 'Ponowne laczenie...',
|
||||
'conn.live': 'Na zywo',
|
||||
'conn.simulated': 'Symulacja',
|
||||
|
||||
// Misc
|
||||
'misc.loading': 'Ladowanie...',
|
||||
'misc.error': 'Wystapil blad',
|
||||
'misc.noData': 'Brak danych',
|
||||
'misc.close': 'Zamknij',
|
||||
'misc.cancel': 'Anuluj',
|
||||
'misc.confirm': 'Potwierdz',
|
||||
'misc.settings': 'Ustawienia',
|
||||
'misc.language': 'Jezyk'
|
||||
}
|
||||
};
|
||||
|
||||
export class I18n {
|
||||
constructor() {
|
||||
this.locale = this.getSavedLocale() || this.detectLocale();
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createSelector();
|
||||
this.applyTranslations();
|
||||
}
|
||||
|
||||
detectLocale() {
|
||||
const lang = navigator.language?.toLowerCase() || 'en';
|
||||
if (lang.startsWith('pl')) return 'pl';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
getSavedLocale() {
|
||||
try { return localStorage.getItem('ruview-locale'); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
saveLocale(locale) {
|
||||
try { localStorage.setItem('ruview-locale', locale); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
t(key) {
|
||||
const dict = translations[this.locale] || translations.en;
|
||||
return dict[key] || translations.en[key] || key;
|
||||
}
|
||||
|
||||
setLocale(locale) {
|
||||
if (!translations[locale]) return;
|
||||
this.locale = locale;
|
||||
this.saveLocale(locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
this.applyTranslations();
|
||||
this.listeners.forEach(cb => { try { cb(locale); } catch { /* noop */ } });
|
||||
}
|
||||
|
||||
onLocaleChange(callback) {
|
||||
this.listeners.push(callback);
|
||||
return () => {
|
||||
const i = this.listeners.indexOf(callback);
|
||||
if (i > -1) this.listeners.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
applyTranslations() {
|
||||
// Translate elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = this.t(key);
|
||||
});
|
||||
|
||||
// Translate placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
// Translate aria-labels
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-aria');
|
||||
el.setAttribute('aria-label', this.t(key));
|
||||
});
|
||||
|
||||
// Update language selector
|
||||
const selector = document.getElementById('lang-selector');
|
||||
if (selector) selector.value = this.locale;
|
||||
}
|
||||
|
||||
createSelector() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'lang-selector-wrap';
|
||||
wrapper.innerHTML = `
|
||||
<select id="lang-selector" class="lang-selector" aria-label="Language">
|
||||
<option value="en">EN</option>
|
||||
<option value="pl">PL</option>
|
||||
</select>
|
||||
`;
|
||||
|
||||
const select = wrapper.querySelector('select');
|
||||
select.value = this.locale;
|
||||
select.addEventListener('change', () => this.setLocale(select.value));
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableLocales() {
|
||||
return Object.keys(translations);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.listeners = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n = new I18n();
|
||||
@@ -0,0 +1,83 @@
|
||||
// Idle Manager - Pauses animations, polling, and WebSocket pings when user is inactive
|
||||
// Reduces CPU/battery usage on idle dashboards
|
||||
|
||||
export class IdleManager {
|
||||
constructor() {
|
||||
this.idleTimeout = 3 * 60 * 1000; // 3 minutes
|
||||
this.isIdle = false;
|
||||
this.timer = null;
|
||||
this.callbacks = { idle: [], active: [] };
|
||||
this.events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.resetTimer();
|
||||
this.events.forEach(evt => {
|
||||
document.addEventListener(evt, () => this.onActivity(), { passive: true, capture: true });
|
||||
});
|
||||
// Also use Page Visibility API
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.goIdle();
|
||||
} else {
|
||||
this.goActive();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onActivity() {
|
||||
if (this.isIdle) {
|
||||
this.goActive();
|
||||
}
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
resetTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.goIdle(), this.idleTimeout);
|
||||
}
|
||||
|
||||
goIdle() {
|
||||
if (this.isIdle) return;
|
||||
this.isIdle = true;
|
||||
console.info('[Idle] User inactive - pausing background tasks');
|
||||
this.notify('idle');
|
||||
document.body.classList.add('user-idle');
|
||||
}
|
||||
|
||||
goActive() {
|
||||
if (!this.isIdle) return;
|
||||
this.isIdle = false;
|
||||
console.info('[Idle] User active - resuming background tasks');
|
||||
this.notify('active');
|
||||
document.body.classList.remove('user-idle');
|
||||
this.resetTimer();
|
||||
}
|
||||
|
||||
onIdle(callback) {
|
||||
this.callbacks.idle.push(callback);
|
||||
return () => {
|
||||
const i = this.callbacks.idle.indexOf(callback);
|
||||
if (i > -1) this.callbacks.idle.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
onActive(callback) {
|
||||
this.callbacks.active.push(callback);
|
||||
return () => {
|
||||
const i = this.callbacks.active.indexOf(callback);
|
||||
if (i > -1) this.callbacks.active.splice(i, 1);
|
||||
};
|
||||
}
|
||||
|
||||
notify(type) {
|
||||
this.callbacks[type].forEach(cb => {
|
||||
try { cb(); } catch (e) { console.error('[Idle] Callback error:', e); }
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.callbacks = { idle: [], active: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Keyboard Shortcuts System
|
||||
// Press '?' to show help overlay, number keys to switch tabs, etc.
|
||||
|
||||
export class KeyboardShortcuts {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.shortcuts = new Map();
|
||||
this.helpVisible = false;
|
||||
this.enabled = true;
|
||||
this.overlay = null;
|
||||
this.registerDefaults();
|
||||
}
|
||||
|
||||
registerDefaults() {
|
||||
this.register('?', 'Show keyboard shortcuts', () => this.toggleHelp());
|
||||
this.register('Escape', 'Close overlay / dialog', () => this.closeAll());
|
||||
this.register('1', 'Switch to Dashboard tab', () => this.switchTab('dashboard'));
|
||||
this.register('2', 'Switch to Hardware tab', () => this.switchTab('hardware'));
|
||||
this.register('3', 'Switch to Live Demo tab', () => this.switchTab('demo'));
|
||||
this.register('4', 'Switch to Architecture tab', () => this.switchTab('architecture'));
|
||||
this.register('5', 'Switch to Performance tab', () => this.switchTab('performance'));
|
||||
this.register('6', 'Switch to Applications tab', () => this.switchTab('applications'));
|
||||
this.register('7', 'Switch to Sensing tab', () => this.switchTab('sensing'));
|
||||
this.register('8', 'Switch to Training tab', () => this.switchTab('training'));
|
||||
this.register('p', 'Toggle performance monitor', () => this.togglePerfMonitor());
|
||||
this.register('t', 'Toggle dark/light theme', () => this.toggleTheme());
|
||||
}
|
||||
|
||||
register(key, description, handler) {
|
||||
this.shortcuts.set(key, { description, handler });
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
this.createOverlay();
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Ignore when typing in inputs
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
|
||||
if (e.key === 'Escape') {
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modified keys (except shift for '?')
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
|
||||
const shortcut = this.shortcuts.get(e.key);
|
||||
if (shortcut) {
|
||||
e.preventDefault();
|
||||
shortcut.handler();
|
||||
}
|
||||
}
|
||||
|
||||
switchTab(tabId) {
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager) {
|
||||
tabManager.switchToTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
togglePerfMonitor() {
|
||||
const event = new CustomEvent('toggle-perf-monitor');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const event = new CustomEvent('toggle-theme');
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
if (this.helpVisible) {
|
||||
this.hideHelp();
|
||||
}
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'shortcuts-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Keyboard shortcuts');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
this.overlay.innerHTML = this.buildHelpHTML();
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.hideHelp();
|
||||
});
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
buildHelpHTML() {
|
||||
const groups = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => /^[1-8]$/.test(key))
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => /^[a-z]$/.test(key))
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
items: Array.from(this.shortcuts.entries())
|
||||
.filter(([key]) => !/^[1-8a-z]$/.test(key))
|
||||
}
|
||||
];
|
||||
|
||||
return `
|
||||
<div class="shortcuts-panel">
|
||||
<div class="shortcuts-header">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<button class="shortcuts-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="shortcuts-body">
|
||||
${groups.map(group => `
|
||||
<div class="shortcuts-group">
|
||||
<h3>${group.title}</h3>
|
||||
${group.items.map(([key, { description }]) => `
|
||||
<div class="shortcut-row">
|
||||
<kbd>${this.formatKey(key)}</kbd>
|
||||
<span>${description}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
formatKey(key) {
|
||||
const map = { Escape: 'Esc', '?': '?' };
|
||||
return map[key] || key.toUpperCase();
|
||||
}
|
||||
|
||||
toggleHelp() {
|
||||
this.helpVisible ? this.hideHelp() : this.showHelp();
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
this.overlay.classList.add('visible');
|
||||
this.helpVisible = true;
|
||||
// Focus close button
|
||||
const closeBtn = this.overlay.querySelector('.shortcuts-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => this.hideHelp();
|
||||
closeBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
hideHelp() {
|
||||
this.overlay.classList.remove('visible');
|
||||
this.helpVisible = false;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Mobile Navigation - Hamburger menu for small screens
|
||||
// Replaces wrapped tab bar with a slide-out drawer on mobile
|
||||
|
||||
export class MobileNav {
|
||||
constructor() {
|
||||
this.drawer = null;
|
||||
this.backdrop = null;
|
||||
this.hamburger = null;
|
||||
this.isOpen = false;
|
||||
this.mql = window.matchMedia('(max-width: 768px)');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createHamburger();
|
||||
this.createDrawer();
|
||||
this.bindEvents();
|
||||
this.onMediaChange(this.mql);
|
||||
}
|
||||
|
||||
createHamburger() {
|
||||
this.hamburger = document.createElement('button');
|
||||
this.hamburger.className = 'mobile-hamburger';
|
||||
this.hamburger.setAttribute('aria-label', 'Open navigation menu');
|
||||
this.hamburger.setAttribute('aria-expanded', 'false');
|
||||
this.hamburger.innerHTML = `
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
`;
|
||||
this.hamburger.addEventListener('click', () => this.toggle());
|
||||
|
||||
const header = document.querySelector('.header');
|
||||
if (header) {
|
||||
header.style.position = 'relative';
|
||||
header.appendChild(this.hamburger);
|
||||
}
|
||||
}
|
||||
|
||||
createDrawer() {
|
||||
// Backdrop
|
||||
this.backdrop = document.createElement('div');
|
||||
this.backdrop.className = 'mobile-nav-backdrop';
|
||||
this.backdrop.addEventListener('click', () => this.close());
|
||||
document.body.appendChild(this.backdrop);
|
||||
|
||||
// Drawer
|
||||
this.drawer = document.createElement('nav');
|
||||
this.drawer.className = 'mobile-nav-drawer';
|
||||
this.drawer.setAttribute('role', 'navigation');
|
||||
this.drawer.setAttribute('aria-label', 'Mobile navigation');
|
||||
|
||||
// Clone tabs into drawer
|
||||
const tabs = document.querySelectorAll('.nav-tabs .nav-tab');
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mobile-nav-list';
|
||||
|
||||
tabs.forEach(tab => {
|
||||
const item = document.createElement(tab.tagName === 'A' ? 'a' : 'button');
|
||||
item.className = 'mobile-nav-item';
|
||||
item.textContent = tab.textContent.trim();
|
||||
|
||||
if (tab.tagName === 'A') {
|
||||
item.href = tab.href;
|
||||
} else {
|
||||
const tabId = tab.getAttribute('data-tab');
|
||||
item.dataset.tab = tabId;
|
||||
if (tab.classList.contains('active')) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
item.addEventListener('click', () => {
|
||||
// Activate tab via the original tab manager
|
||||
tab.click();
|
||||
this.close();
|
||||
// Update active states in drawer
|
||||
list.querySelectorAll('.mobile-nav-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
this.drawer.appendChild(list);
|
||||
|
||||
// Keyboard hint at bottom
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'mobile-nav-hint';
|
||||
hint.textContent = 'Tip: Press Ctrl+K for command palette';
|
||||
this.drawer.appendChild(hint);
|
||||
|
||||
document.body.appendChild(this.drawer);
|
||||
|
||||
// Sync active tab when tabs change externally
|
||||
const observer = new MutationObserver(() => {
|
||||
const activeTab = document.querySelector('.nav-tabs .nav-tab.active');
|
||||
if (activeTab) {
|
||||
const activeId = activeTab.getAttribute('data-tab');
|
||||
list.querySelectorAll('.mobile-nav-item').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.tab === activeId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const navTabs = document.querySelector('.nav-tabs');
|
||||
if (navTabs) {
|
||||
observer.observe(navTabs, { attributes: true, subtree: true, attributeFilter: ['class'] });
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Listen for media query changes
|
||||
this.mql.addEventListener('change', (e) => this.onMediaChange(e));
|
||||
|
||||
// Close on escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) this.close();
|
||||
});
|
||||
|
||||
// Swipe to close
|
||||
let touchStartX = 0;
|
||||
this.drawer.addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
}, { passive: true });
|
||||
this.drawer.addEventListener('touchend', (e) => {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX;
|
||||
if (deltaX < -50) this.close(); // Swipe left to close
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
onMediaChange(mql) {
|
||||
const isMobile = mql.matches !== undefined ? mql.matches : mql;
|
||||
document.body.classList.toggle('mobile-nav-active', isMobile);
|
||||
|
||||
if (!isMobile && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.drawer.classList.add('open');
|
||||
this.backdrop.classList.add('open');
|
||||
this.hamburger.classList.add('open');
|
||||
this.hamburger.setAttribute('aria-expanded', 'true');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus first item
|
||||
const first = this.drawer.querySelector('.mobile-nav-item');
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.drawer.classList.remove('open');
|
||||
this.backdrop.classList.remove('open');
|
||||
this.hamburger.classList.remove('open');
|
||||
this.hamburger.setAttribute('aria-expanded', 'false');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.close();
|
||||
this.hamburger?.remove();
|
||||
this.drawer?.remove();
|
||||
this.backdrop?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Notification Center - Bell icon with event history
|
||||
// Persists notifications across page views (sessionStorage)
|
||||
|
||||
export class NotificationCenter {
|
||||
constructor() {
|
||||
this.button = null;
|
||||
this.panel = null;
|
||||
this.notifications = [];
|
||||
this.maxNotifications = 50;
|
||||
this.isOpen = false;
|
||||
this.unreadCount = 0;
|
||||
this.storageKey = 'ruview-notifications';
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadFromStorage();
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
this.interceptEvents();
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'notif-bell';
|
||||
this.button.setAttribute('aria-label', 'Notifications');
|
||||
this.button.setAttribute('title', 'Notifications');
|
||||
this.button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<span class="notif-badge" style="display:none">0</span>
|
||||
`;
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.button);
|
||||
}
|
||||
|
||||
this.updateBadge();
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'notif-panel';
|
||||
this.panel.setAttribute('role', 'region');
|
||||
this.panel.setAttribute('aria-label', 'Notification history');
|
||||
this.panel.innerHTML = `
|
||||
<div class="notif-panel-header">
|
||||
<span>Notifications</span>
|
||||
<div class="notif-panel-actions">
|
||||
<button class="notif-mark-read" title="Mark all read">Mark read</button>
|
||||
<button class="notif-clear" title="Clear all">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-panel-body"></div>
|
||||
`;
|
||||
|
||||
this.panel.querySelector('.notif-mark-read').addEventListener('click', () => {
|
||||
this.notifications.forEach(n => n.read = true);
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.renderList();
|
||||
this.saveToStorage();
|
||||
});
|
||||
|
||||
this.panel.querySelector('.notif-clear').addEventListener('click', () => {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.renderList();
|
||||
this.saveToStorage();
|
||||
});
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interceptEvents() {
|
||||
// Listen for toast events to capture as notifications
|
||||
const origInfo = console.info;
|
||||
console.info = (...args) => {
|
||||
origInfo.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
// Only capture app-relevant messages
|
||||
if (msg.includes('[WS-') || msg.includes('Backend') || msg.includes('Service worker') ||
|
||||
msg.includes('connected') || msg.includes('initialized') || msg.includes('sensing')) {
|
||||
this.add(msg, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
origWarn.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
if (msg.includes('Backend') || msg.includes('unavailable') || msg.includes('[WS-') ||
|
||||
msg.includes('connection') || msg.includes('timeout')) {
|
||||
this.add(msg, 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const origError = console.error;
|
||||
console.error = (...args) => {
|
||||
origError.apply(console, args);
|
||||
const msg = args.map(String).join(' ');
|
||||
if (msg.includes('Failed') || msg.includes('Error') || msg.includes('error')) {
|
||||
this.add(msg, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
add(message, type = 'info') {
|
||||
const notification = {
|
||||
id: Date.now() + Math.random(),
|
||||
message: this.truncate(message, 200),
|
||||
type,
|
||||
time: new Date().toISOString(),
|
||||
read: false
|
||||
};
|
||||
|
||||
this.notifications.unshift(notification);
|
||||
if (this.notifications.length > this.maxNotifications) {
|
||||
this.notifications.pop();
|
||||
}
|
||||
|
||||
this.unreadCount++;
|
||||
this.updateBadge();
|
||||
this.saveToStorage();
|
||||
|
||||
if (this.isOpen) {
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.panel.classList.add('open');
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.panel.classList.remove('open');
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const body = this.panel.querySelector('.notif-panel-body');
|
||||
if (this.notifications.length === 0) {
|
||||
body.innerHTML = '<div class="notif-empty">No notifications</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = this.notifications.map(n => {
|
||||
const time = new Date(n.time);
|
||||
const ago = this.timeAgo(time);
|
||||
return `
|
||||
<div class="notif-item notif-${n.type} ${n.read ? 'read' : 'unread'}">
|
||||
<div class="notif-item-dot"></div>
|
||||
<div class="notif-item-content">
|
||||
<span class="notif-item-msg">${this.escapeHtml(n.message)}</span>
|
||||
<span class="notif-item-time">${ago}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
updateBadge() {
|
||||
const badge = this.button?.querySelector('.notif-badge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.style.display = '';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
const seconds = Math.floor((new Date() - date) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
loadFromStorage() {
|
||||
try {
|
||||
const data = sessionStorage.getItem(this.storageKey);
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
this.notifications = parsed.notifications || [];
|
||||
this.unreadCount = parsed.unreadCount || 0;
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
saveToStorage() {
|
||||
try {
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify({
|
||||
notifications: this.notifications.slice(0, 20),
|
||||
unreadCount: this.unreadCount
|
||||
}));
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.close();
|
||||
this.button?.remove();
|
||||
this.panel?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Onboarding Tour - Interactive first-run walkthrough
|
||||
// Shows on first visit, can be re-triggered from command palette or help
|
||||
|
||||
const STORAGE_KEY = 'ruview-onboarding-done';
|
||||
|
||||
export class Onboarding {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.overlay = null;
|
||||
this.currentStep = 0;
|
||||
this.steps = [];
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.defineSteps();
|
||||
document.addEventListener('start-onboarding', () => this.start());
|
||||
|
||||
// Auto-start on first visit
|
||||
if (!this.isDone()) {
|
||||
// Delay to let the app render first
|
||||
setTimeout(() => this.start(), 800);
|
||||
}
|
||||
}
|
||||
|
||||
defineSteps() {
|
||||
this.steps = [
|
||||
{
|
||||
title: 'Welcome to RuView',
|
||||
text: 'WiFi-based human pose estimation that works through walls. Let\'s take a quick tour of the dashboard.',
|
||||
target: null, // No highlight, centered
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
title: 'System Status',
|
||||
text: 'Monitor your WiFi sensing hardware and API server status in real time. Green means everything is connected.',
|
||||
target: '.live-status-panel',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
title: 'Live Demo',
|
||||
text: 'Switch to the Live Demo tab to see real-time pose detection. Connect an ESP32 sensor or use the built-in simulation.',
|
||||
target: '[data-tab="demo"]',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
title: 'Sensing Visualization',
|
||||
text: 'The Sensing tab shows a 3D Gaussian splat visualization of WiFi signal fields, with real-time metrics.',
|
||||
target: '[data-tab="sensing"]',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
text: 'Press ? for shortcuts, Ctrl+K for the command palette, or use number keys 1-8 to switch tabs quickly.',
|
||||
target: null,
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
title: 'You\'re all set!',
|
||||
text: 'Explore the dashboard, connect hardware, or start the demo. You can replay this tour anytime from the command palette.',
|
||||
target: null,
|
||||
position: 'center'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
isDone() {
|
||||
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
markDone() {
|
||||
try { localStorage.setItem(STORAGE_KEY, 'true'); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
start() {
|
||||
this.currentStep = 0;
|
||||
this.active = true;
|
||||
this.createOverlay();
|
||||
this.showStep();
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
// Remove existing if any
|
||||
this.removeOverlay();
|
||||
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'onboarding-overlay';
|
||||
this.overlay.setAttribute('role', 'dialog');
|
||||
this.overlay.setAttribute('aria-label', 'Onboarding tour');
|
||||
this.overlay.setAttribute('aria-modal', 'true');
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
showStep() {
|
||||
if (this.currentStep >= this.steps.length) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const step = this.steps[this.currentStep];
|
||||
const total = this.steps.length;
|
||||
const isFirst = this.currentStep === 0;
|
||||
const isLast = this.currentStep === total - 1;
|
||||
|
||||
// Clear highlight
|
||||
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
|
||||
|
||||
// Highlight target
|
||||
let targetRect = null;
|
||||
if (step.target) {
|
||||
const targetEl = document.querySelector(step.target);
|
||||
if (targetEl) {
|
||||
targetEl.classList.add('onboarding-highlight');
|
||||
targetRect = targetEl.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
this.overlay.innerHTML = `
|
||||
<div class="onboarding-backdrop"></div>
|
||||
<div class="onboarding-tooltip ${step.position}" ${targetRect ? `style="${this.positionTooltip(targetRect, step.position)}"` : ''}>
|
||||
<div class="onboarding-progress">
|
||||
${Array.from({ length: total }, (_, i) =>
|
||||
`<span class="onboarding-dot ${i === this.currentStep ? 'active' : i < this.currentStep ? 'done' : ''}"></span>`
|
||||
).join('')}
|
||||
</div>
|
||||
<h3 class="onboarding-title">${step.title}</h3>
|
||||
<p class="onboarding-text">${step.text}</p>
|
||||
<div class="onboarding-actions">
|
||||
<button class="onboarding-skip">Skip tour</button>
|
||||
<div class="onboarding-nav">
|
||||
${!isFirst ? '<button class="onboarding-prev">Back</button>' : ''}
|
||||
<button class="onboarding-next">${isLast ? 'Get started' : 'Next'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
this.overlay.querySelector('.onboarding-skip').addEventListener('click', () => this.finish());
|
||||
this.overlay.querySelector('.onboarding-next').addEventListener('click', () => {
|
||||
this.currentStep++;
|
||||
this.showStep();
|
||||
});
|
||||
const prevBtn = this.overlay.querySelector('.onboarding-prev');
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
this.currentStep--;
|
||||
this.showStep();
|
||||
});
|
||||
}
|
||||
this.overlay.querySelector('.onboarding-backdrop').addEventListener('click', () => this.finish());
|
||||
|
||||
// Focus next button
|
||||
this.overlay.querySelector('.onboarding-next').focus();
|
||||
|
||||
// Escape to close
|
||||
this._escHandler = (e) => { if (e.key === 'Escape') this.finish(); };
|
||||
document.addEventListener('keydown', this._escHandler);
|
||||
}
|
||||
|
||||
positionTooltip(rect, position) {
|
||||
const margin = 12;
|
||||
if (position === 'bottom') {
|
||||
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; top: ${rect.bottom + margin}px;`;
|
||||
}
|
||||
if (position === 'top') {
|
||||
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; bottom: ${window.innerHeight - rect.top + margin}px;`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
finish() {
|
||||
this.active = false;
|
||||
this.markDone();
|
||||
this.removeOverlay();
|
||||
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
|
||||
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
|
||||
}
|
||||
|
||||
removeOverlay() {
|
||||
if (this.overlay?.parentNode) {
|
||||
this.overlay.parentNode.removeChild(this.overlay);
|
||||
this.overlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Performance Monitor Overlay
|
||||
// Shows FPS, memory usage, and network latency in real-time
|
||||
|
||||
export class PerfMonitor {
|
||||
constructor() {
|
||||
this.visible = false;
|
||||
this.panel = null;
|
||||
this.frames = [];
|
||||
this.lastFrameTime = 0;
|
||||
this.rafId = null;
|
||||
this.latencyHistory = [];
|
||||
this.maxHistory = 60;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPanel();
|
||||
document.addEventListener('toggle-perf-monitor', () => this.toggle());
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'perf-monitor';
|
||||
this.panel.setAttribute('role', 'status');
|
||||
this.panel.setAttribute('aria-label', 'Performance monitor');
|
||||
this.panel.innerHTML = `
|
||||
<div class="perf-header">
|
||||
<span>PERF</span>
|
||||
<button class="perf-close" aria-label="Close performance monitor">×</button>
|
||||
</div>
|
||||
<div class="perf-metrics">
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">FPS</span>
|
||||
<span class="perf-value" data-metric="fps">--</span>
|
||||
<canvas class="perf-spark" data-spark="fps" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">MEM</span>
|
||||
<span class="perf-value" data-metric="memory">--</span>
|
||||
<canvas class="perf-spark" data-spark="memory" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">LAT</span>
|
||||
<span class="perf-value" data-metric="latency">--</span>
|
||||
<canvas class="perf-spark" data-spark="latency" width="60" height="20"></canvas>
|
||||
</div>
|
||||
<div class="perf-row">
|
||||
<span class="perf-label">DOM</span>
|
||||
<span class="perf-value" data-metric="dom">--</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.panel.querySelector('.perf-close').addEventListener('click', () => this.hide());
|
||||
|
||||
// Make it draggable
|
||||
this.makeDraggable();
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
this.sparkData = {
|
||||
fps: [],
|
||||
memory: [],
|
||||
latency: []
|
||||
};
|
||||
}
|
||||
|
||||
makeDraggable() {
|
||||
const header = this.panel.querySelector('.perf-header');
|
||||
let dragging = false;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
dragging = true;
|
||||
offsetX = e.clientX - this.panel.offsetLeft;
|
||||
offsetY = e.clientY - this.panel.offsetTop;
|
||||
header.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!dragging) return;
|
||||
this.panel.style.left = `${e.clientX - offsetX}px`;
|
||||
this.panel.style.top = `${e.clientY - offsetY}px`;
|
||||
this.panel.style.right = 'auto';
|
||||
this.panel.style.bottom = 'auto';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
dragging = false;
|
||||
header.style.cursor = 'grab';
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.visible ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.panel.classList.add('visible');
|
||||
this.visible = true;
|
||||
this.lastFrameTime = performance.now();
|
||||
this.tick();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.panel.classList.remove('visible');
|
||||
this.visible = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.visible) return;
|
||||
|
||||
const now = performance.now();
|
||||
this.frames.push(now);
|
||||
|
||||
// Keep only last second of frames
|
||||
while (this.frames.length > 0 && this.frames[0] < now - 1000) {
|
||||
this.frames.shift();
|
||||
}
|
||||
|
||||
const fps = this.frames.length;
|
||||
this.updateMetric('fps', fps, 'fps');
|
||||
this.pushSpark('fps', fps, 0, 120);
|
||||
|
||||
// Memory (if available)
|
||||
if (performance.memory) {
|
||||
const mb = Math.round(performance.memory.usedJSHeapSize / (1024 * 1024));
|
||||
const total = Math.round(performance.memory.jsHeapSizeLimit / (1024 * 1024));
|
||||
this.updateMetric('memory', `${mb}MB`, mb > total * 0.8 ? 'warning' : 'ok');
|
||||
this.pushSpark('memory', mb, 0, total);
|
||||
} else {
|
||||
this.updateMetric('memory', 'N/A', 'na');
|
||||
}
|
||||
|
||||
// DOM node count
|
||||
const domNodes = document.querySelectorAll('*').length;
|
||||
this.updateMetric('dom', domNodes, domNodes > 3000 ? 'warning' : 'ok');
|
||||
|
||||
// Estimate latency from last navigation or resource timing
|
||||
this.measureLatency();
|
||||
|
||||
this.rafId = requestAnimationFrame(() => this.tick());
|
||||
}
|
||||
|
||||
measureLatency() {
|
||||
const entries = performance.getEntriesByType('resource');
|
||||
if (entries.length > 0) {
|
||||
const last = entries[entries.length - 1];
|
||||
const latency = Math.round(last.responseEnd - last.requestStart);
|
||||
if (latency > 0 && latency < 30000) {
|
||||
this.latencyHistory.push(latency);
|
||||
if (this.latencyHistory.length > this.maxHistory) {
|
||||
this.latencyHistory.shift();
|
||||
}
|
||||
const avg = Math.round(
|
||||
this.latencyHistory.reduce((a, b) => a + b, 0) / this.latencyHistory.length
|
||||
);
|
||||
this.updateMetric('latency', `${avg}ms`, avg > 500 ? 'warning' : 'ok');
|
||||
this.pushSpark('latency', avg, 0, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateMetric(metric, value, status) {
|
||||
const el = this.panel.querySelector(`[data-metric="${metric}"]`);
|
||||
if (!el) return;
|
||||
el.textContent = value;
|
||||
el.className = `perf-value perf-${status}`;
|
||||
}
|
||||
|
||||
pushSpark(name, value, min, max) {
|
||||
const data = this.sparkData[name];
|
||||
if (!data) return;
|
||||
data.push(value);
|
||||
if (data.length > 60) data.shift();
|
||||
this.drawSpark(name, data, min, max);
|
||||
}
|
||||
|
||||
drawSpark(name, data, min, max) {
|
||||
const canvas = this.panel.querySelector(`[data-spark="${name}"]`);
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (data.length < 2) return;
|
||||
|
||||
const range = max - min || 1;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(50, 184, 198, 0.8)';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
data.forEach((val, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - ((val - min) / range) * h;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.hide();
|
||||
if (this.panel?.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Quick Settings Panel - Centralized configuration for all UI features
|
||||
// Accessible via gear icon in header
|
||||
|
||||
export class QuickSettings {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.button = null;
|
||||
this.panel = null;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'settings-gear';
|
||||
this.button.setAttribute('aria-label', 'Settings');
|
||||
this.button.setAttribute('title', 'Quick settings');
|
||||
this.button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
|
||||
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) headerInfo.appendChild(this.button);
|
||||
}
|
||||
|
||||
createPanel() {
|
||||
this.panel = document.createElement('div');
|
||||
this.panel.className = 'quick-settings-panel';
|
||||
this.panel.setAttribute('role', 'dialog');
|
||||
this.panel.setAttribute('aria-label', 'Quick settings');
|
||||
|
||||
this.panel.innerHTML = `
|
||||
<div class="qs-header">
|
||||
<h3>Settings</h3>
|
||||
<button class="qs-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="qs-body">
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Display</div>
|
||||
<label class="qs-toggle">
|
||||
<span>Reduced motion</span>
|
||||
<input type="checkbox" id="qs-reduced-motion" ${this.prefersReducedMotion() ? 'checked' : ''}>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
<label class="qs-toggle">
|
||||
<span>High contrast</span>
|
||||
<input type="checkbox" id="qs-high-contrast">
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
<label class="qs-toggle">
|
||||
<span>Compact mode</span>
|
||||
<input type="checkbox" id="qs-compact" ${this.getSetting('compact') ? 'checked' : ''}>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Monitoring</div>
|
||||
<label class="qs-toggle">
|
||||
<span>Health polling</span>
|
||||
<input type="checkbox" id="qs-health-polling" checked>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
<label class="qs-toggle">
|
||||
<span>Auto-reconnect</span>
|
||||
<input type="checkbox" id="qs-auto-reconnect" checked>
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Data</div>
|
||||
<div class="qs-row">
|
||||
<span>Clear local data</span>
|
||||
<button class="qs-btn-danger" id="qs-clear-data">Clear</button>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<span>Reset onboarding</span>
|
||||
<button class="qs-btn" id="qs-reset-tour">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
|
||||
|
||||
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('reduced-motion', e.target.checked);
|
||||
this.saveSetting('reduced-motion', e.target.checked);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-high-contrast').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('high-contrast', e.target.checked);
|
||||
this.saveSetting('high-contrast', e.target.checked);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-compact').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('compact-mode', e.target.checked);
|
||||
this.saveSetting('compact', e.target.checked);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-health-polling').addEventListener('change', (e) => {
|
||||
const healthService = this.app?.components?.dashboard?.healthSubscription;
|
||||
if (e.target.checked) {
|
||||
// Resume would need import - just dispatch event
|
||||
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: true }));
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: false }));
|
||||
}
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
} catch { /* noop */ }
|
||||
this.close();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-reset-tour').addEventListener('click', () => {
|
||||
try { localStorage.removeItem('ruview-onboarding-done'); } catch { /* noop */ }
|
||||
this.close();
|
||||
document.dispatchEvent(new CustomEvent('start-onboarding'));
|
||||
});
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Apply saved settings on init
|
||||
this.applySavedSettings();
|
||||
}
|
||||
|
||||
applySavedSettings() {
|
||||
if (this.getSetting('reduced-motion') || this.prefersReducedMotion()) {
|
||||
document.body.classList.add('reduced-motion');
|
||||
const cb = this.panel.querySelector('#qs-reduced-motion');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
if (this.getSetting('high-contrast')) {
|
||||
document.body.classList.add('high-contrast');
|
||||
const cb = this.panel.querySelector('#qs-high-contrast');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
if (this.getSetting('compact')) {
|
||||
document.body.classList.add('compact-mode');
|
||||
}
|
||||
}
|
||||
|
||||
prefersReducedMotion() {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.panel.classList.add('open');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.panel.classList.remove('open');
|
||||
}
|
||||
|
||||
getSetting(key) {
|
||||
try { return JSON.parse(localStorage.getItem(`ruview-setting-${key}`)); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
saveSetting(key, value) {
|
||||
try { localStorage.setItem(`ruview-setting-${key}`, JSON.stringify(value)); }
|
||||
catch { /* noop */ }
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.button?.remove();
|
||||
this.panel?.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Hash Router - Makes tabs bookmarkable and shareable
|
||||
// URL format: #dashboard, #demo, #sensing, etc.
|
||||
|
||||
export class Router {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.validTabs = ['dashboard', 'hardware', 'demo', 'architecture', 'performance', 'applications', 'sensing', 'training'];
|
||||
}
|
||||
|
||||
init() {
|
||||
// Navigate to hash on load
|
||||
this.onHashChange();
|
||||
|
||||
// Listen for hash changes (back/forward navigation)
|
||||
window.addEventListener('hashchange', () => this.onHashChange());
|
||||
|
||||
// Update hash when tab changes
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager) {
|
||||
tabManager.onTabChange((tabId) => {
|
||||
this.setHash(tabId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onHashChange() {
|
||||
const hash = window.location.hash.replace('#', '').toLowerCase();
|
||||
if (hash && this.validTabs.includes(hash)) {
|
||||
const tabManager = this.app?.getComponent?.('tabManager');
|
||||
if (tabManager && tabManager.getActiveTab() !== hash) {
|
||||
tabManager.switchToTab(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHash(tabId) {
|
||||
// Only update if different to avoid infinite loop
|
||||
const current = window.location.hash.replace('#', '');
|
||||
if (current !== tabId) {
|
||||
history.replaceState(null, '', `#${tabId}`);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// No explicit cleanup needed - event listeners are on window
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Screenshot Tool - Capture current tab view as PNG
|
||||
// Uses html2canvas-like approach with native Canvas API
|
||||
|
||||
import { toastManager } from './toast.js';
|
||||
|
||||
export class ScreenshotTool {
|
||||
constructor() {
|
||||
this.capturing = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('take-screenshot', () => this.capture());
|
||||
}
|
||||
|
||||
async capture() {
|
||||
if (this.capturing) return;
|
||||
this.capturing = true;
|
||||
|
||||
const activeTab = document.querySelector('.tab-content.active');
|
||||
if (!activeTab) {
|
||||
toastManager.warning('No active tab to capture');
|
||||
this.capturing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Flash effect
|
||||
this.flashEffect();
|
||||
|
||||
// Try native ClipboardItem API first (modern browsers)
|
||||
if (typeof ClipboardItem !== 'undefined') {
|
||||
await this.captureToClipboard(activeTab);
|
||||
toastManager.success('Screenshot copied to clipboard', { duration: 3000 });
|
||||
} else {
|
||||
// Fallback: download as file
|
||||
await this.captureToFile(activeTab);
|
||||
toastManager.success('Screenshot saved as file', { duration: 3000 });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Screenshot failed:', err);
|
||||
// Fallback: capture visible canvases + basic layout
|
||||
try {
|
||||
await this.captureCanvasFallback(activeTab);
|
||||
toastManager.success('Screenshot saved (canvas only)', { duration: 3000 });
|
||||
} catch {
|
||||
toastManager.error('Screenshot failed. Try using browser\'s built-in screenshot tool.');
|
||||
}
|
||||
}
|
||||
|
||||
this.capturing = false;
|
||||
}
|
||||
|
||||
async captureToClipboard(element) {
|
||||
const canvas = await this.renderToCanvas(element);
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob })
|
||||
]);
|
||||
}
|
||||
|
||||
async captureToFile(element) {
|
||||
const canvas = await this.renderToCanvas(element);
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `ruview-screenshot-${this.timestamp()}.png`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
async captureCanvasFallback(element) {
|
||||
// Find any canvas elements and merge them
|
||||
const canvases = element.querySelectorAll('canvas');
|
||||
if (canvases.length === 0) throw new Error('No canvas elements found');
|
||||
|
||||
const firstCanvas = canvases[0];
|
||||
const mergedCanvas = document.createElement('canvas');
|
||||
mergedCanvas.width = firstCanvas.width || 800;
|
||||
mergedCanvas.height = firstCanvas.height || 600;
|
||||
const ctx = mergedCanvas.getContext('2d');
|
||||
|
||||
// Dark background
|
||||
ctx.fillStyle = '#1f2121';
|
||||
ctx.fillRect(0, 0, mergedCanvas.width, mergedCanvas.height);
|
||||
|
||||
canvases.forEach(c => {
|
||||
try { ctx.drawImage(c, 0, 0); } catch { /* tainted canvas */ }
|
||||
});
|
||||
|
||||
// Add timestamp watermark
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.font = '12px monospace';
|
||||
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, mergedCanvas.height - 10);
|
||||
|
||||
const dataUrl = mergedCanvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = `ruview-screenshot-${this.timestamp()}.png`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
async renderToCanvas(element) {
|
||||
// Simple DOM-to-canvas renderer for basic content
|
||||
const rect = element.getBoundingClientRect();
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * scale;
|
||||
canvas.height = rect.height * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Render background
|
||||
const styles = getComputedStyle(element);
|
||||
ctx.fillStyle = styles.backgroundColor || '#1f2121';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Render existing canvases
|
||||
const canvases = element.querySelectorAll('canvas');
|
||||
canvases.forEach(c => {
|
||||
const cRect = c.getBoundingClientRect();
|
||||
const x = cRect.left - rect.left;
|
||||
const y = cRect.top - rect.top;
|
||||
try { ctx.drawImage(c, x, y, cRect.width, cRect.height); } catch { /* tainted */ }
|
||||
});
|
||||
|
||||
// Render text content
|
||||
ctx.fillStyle = styles.color || '#e0e0e0';
|
||||
ctx.font = `14px ${styles.fontFamily || 'sans-serif'}`;
|
||||
let textY = 30;
|
||||
element.querySelectorAll('h2, h3, .stat-value, .metric-label').forEach(el => {
|
||||
const text = el.textContent.trim();
|
||||
if (text && textY < rect.height - 20) {
|
||||
const elStyles = getComputedStyle(el);
|
||||
ctx.font = `${elStyles.fontWeight} ${elStyles.fontSize} ${styles.fontFamily || 'sans-serif'}`;
|
||||
ctx.fillStyle = elStyles.color;
|
||||
ctx.fillText(text, 20, textY);
|
||||
textY += parseInt(elStyles.fontSize) + 8;
|
||||
}
|
||||
});
|
||||
|
||||
// Watermark
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, rect.height - 10);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
flashEffect() {
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'screenshot-flash';
|
||||
document.body.appendChild(flash);
|
||||
flash.addEventListener('animationend', () => flash.remove());
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
dispose() {}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Theme Toggle - Manual dark/light mode switch with persistence
|
||||
|
||||
export class ThemeToggle {
|
||||
constructor() {
|
||||
this.button = null;
|
||||
this.currentTheme = this.getSavedTheme() || this.getSystemTheme();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createButton();
|
||||
this.applyTheme(this.currentTheme);
|
||||
document.addEventListener('toggle-theme', () => this.toggle());
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!this.getSavedTheme()) {
|
||||
this.applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'theme-toggle';
|
||||
this.button.setAttribute('aria-label', 'Toggle dark/light theme');
|
||||
this.button.setAttribute('title', 'Toggle theme (T)');
|
||||
this.updateIcon();
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
|
||||
// Insert into header
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.prepend(this.button);
|
||||
} else {
|
||||
const header = document.querySelector('.header');
|
||||
if (header) header.appendChild(this.button);
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
||||
this.applyTheme(this.currentTheme);
|
||||
this.saveTheme(this.currentTheme);
|
||||
}
|
||||
|
||||
applyTheme(theme) {
|
||||
this.currentTheme = theme;
|
||||
document.documentElement.setAttribute('data-color-scheme', theme);
|
||||
this.updateIcon();
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
if (!this.button) return;
|
||||
const isDark = this.currentTheme === 'dark';
|
||||
this.button.innerHTML = isDark
|
||||
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||||
this.button.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
|
||||
}
|
||||
|
||||
getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
getSavedTheme() {
|
||||
try {
|
||||
return localStorage.getItem('ruview-theme');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
saveTheme(theme) {
|
||||
try {
|
||||
localStorage.setItem('ruview-theme', theme);
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.button?.parentNode) {
|
||||
this.button.parentNode.removeChild(this.button);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Enhanced Toast Notification System
|
||||
// Supports multiple types: success, error, warning, info
|
||||
// Stacking, auto-dismiss, manual close, progress bar
|
||||
|
||||
export class ToastManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.toasts = [];
|
||||
this.idCounter = 0;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'toast-container';
|
||||
this.container.setAttribute('role', 'region');
|
||||
this.container.setAttribute('aria-label', 'Notifications');
|
||||
this.container.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
show(message, options = {}) {
|
||||
const {
|
||||
type = 'info',
|
||||
duration = 5000,
|
||||
closable = true,
|
||||
icon = null,
|
||||
action = null
|
||||
} = options;
|
||||
|
||||
const id = ++this.idCounter;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.dataset.toastId = id;
|
||||
|
||||
const iconMap = {
|
||||
success: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
error: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||
warning: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 5v4M8 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7.13 2.22L1.09 12.5a1 1 0 00.87 1.5h12.08a1 1 0 00.87-1.5L8.87 2.22a1 1 0 00-1.74 0z" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
info: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
|
||||
};
|
||||
|
||||
const displayIcon = icon || iconMap[type] || iconMap.info;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon">${displayIcon}</div>
|
||||
<div class="toast-content">
|
||||
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||||
${action ? `<button class="toast-action">${this.escapeHtml(action.label)}</button>` : ''}
|
||||
</div>
|
||||
${closable ? '<button class="toast-dismiss" aria-label="Dismiss">×</button>' : ''}
|
||||
${duration > 0 ? '<div class="toast-progress"><div class="toast-progress-bar"></div></div>' : ''}
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
if (closable) {
|
||||
toast.querySelector('.toast-dismiss').addEventListener('click', () => this.dismiss(id));
|
||||
}
|
||||
if (action?.onClick) {
|
||||
toast.querySelector('.toast-action')?.addEventListener('click', () => {
|
||||
action.onClick();
|
||||
this.dismiss(id);
|
||||
});
|
||||
}
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
// Trigger enter animation
|
||||
requestAnimationFrame(() => toast.classList.add('toast-enter'));
|
||||
|
||||
// Auto-dismiss
|
||||
let timeoutId = null;
|
||||
if (duration > 0) {
|
||||
const progressBar = toast.querySelector('.toast-progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.animationDuration = `${duration}ms`;
|
||||
progressBar.classList.add('toast-progress-animate');
|
||||
}
|
||||
timeoutId = setTimeout(() => this.dismiss(id), duration);
|
||||
}
|
||||
|
||||
// Pause on hover
|
||||
toast.addEventListener('mouseenter', () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
const bar = toast.querySelector('.toast-progress-bar');
|
||||
if (bar) bar.style.animationPlayState = 'paused';
|
||||
}
|
||||
});
|
||||
toast.addEventListener('mouseleave', () => {
|
||||
if (duration > 0) {
|
||||
const bar = toast.querySelector('.toast-progress-bar');
|
||||
if (bar) bar.style.animationPlayState = 'running';
|
||||
timeoutId = setTimeout(() => this.dismiss(id), duration / 2);
|
||||
}
|
||||
});
|
||||
|
||||
this.toasts.push({ id, toast, timeoutId });
|
||||
return id;
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
const index = this.toasts.findIndex(t => t.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const { toast, timeoutId } = this.toasts[index];
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
toast.classList.add('toast-exit');
|
||||
toast.addEventListener('animationend', () => {
|
||||
toast.remove();
|
||||
}, { once: true });
|
||||
|
||||
this.toasts.splice(index, 1);
|
||||
}
|
||||
|
||||
success(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'success' });
|
||||
}
|
||||
|
||||
error(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'error', duration: options.duration || 8000 });
|
||||
}
|
||||
|
||||
warning(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'warning', duration: options.duration || 6000 });
|
||||
}
|
||||
|
||||
info(message, options = {}) {
|
||||
return this.show(message, { ...options, type: 'info' });
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.toasts.forEach(({ timeoutId }) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
this.toasts = [];
|
||||
if (this.container?.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toastManager = new ToastManager();
|
||||
@@ -0,0 +1,61 @@
|
||||
// Uptime Clock - Shows system uptime and current time in header
|
||||
|
||||
export class UptimeClock {
|
||||
constructor() {
|
||||
this.widget = null;
|
||||
this.startTime = Date.now();
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createWidget();
|
||||
this.update();
|
||||
this.intervalId = setInterval(() => this.update(), 1000);
|
||||
}
|
||||
|
||||
createWidget() {
|
||||
this.widget = document.createElement('div');
|
||||
this.widget.className = 'uptime-clock';
|
||||
this.widget.setAttribute('aria-label', 'System uptime');
|
||||
this.widget.innerHTML = `
|
||||
<span class="uptime-time"></span>
|
||||
<span class="uptime-separator">|</span>
|
||||
<span class="uptime-duration" title="Session uptime"></span>
|
||||
`;
|
||||
|
||||
const headerInfo = document.querySelector('.header-info');
|
||||
if (headerInfo) {
|
||||
headerInfo.appendChild(this.widget);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.widget) return;
|
||||
|
||||
// Current time
|
||||
const now = new Date();
|
||||
const time = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
this.widget.querySelector('.uptime-time').textContent = time;
|
||||
|
||||
// Uptime
|
||||
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
||||
this.widget.querySelector('.uptime-duration').textContent = this.formatDuration(elapsed);
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.intervalId) clearInterval(this.intervalId);
|
||||
this.widget?.remove();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user