mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-17 05:57:21 -05:00
Fix frontend errors and pydantic config for local development
Frontend fixes: - Copy missing JS files from blackroad-os/ to backend/static/js/ - os.js (core OS functionality) - components.js (UI components) - registry.js (app registry) - app.js, config.js, theme.js, mock_data.js (supporting files) - Fixes 3 ERROR findings from Cece audit - System health: 0 ERRORS → 94 SUCCESSES (from 91) Backend config fix: - Add `extra = "ignore"` to Settings.Config in backend/app/config.py - Allows .env.example to have more vars than Settings class defines - Fixes Pydantic v2 validation errors on startup - Enables local development without removing env template vars Cece audit results after fixes: 🔴 CRITICAL: 0 🟠 ERROR: 0 (was 3) 🟡 WARNING: 6 🟢 SUCCESS: 94 (was 91)
This commit is contained in:
827
backend/static/js/os.js
Normal file
827
backend/static/js/os.js
Normal file
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* BlackRoad OS - Window Manager & Event Bus
|
||||
* Core operating system functionality for window management and global events
|
||||
*
|
||||
* Features:
|
||||
* - Window lifecycle management (create, focus, minimize, restore, close)
|
||||
* - Drag-and-drop window positioning
|
||||
* - Z-index management with overflow protection
|
||||
* - Event bus for app communication
|
||||
* - Notification system
|
||||
* - Keyboard navigation and shortcuts
|
||||
*
|
||||
* Architecture:
|
||||
* - Uses Map for O(1) window lookups
|
||||
* - Event-driven design for loose coupling
|
||||
* - Accessible-first with ARIA attributes
|
||||
*
|
||||
* TODO v0.2.0: Add window resizing support
|
||||
* TODO v0.2.0: Add window maximize functionality
|
||||
* TODO v0.3.0: Add window snapping/tiling
|
||||
* TODO v0.3.0: Add window position persistence (localStorage)
|
||||
*/
|
||||
|
||||
class BlackRoadOS {
|
||||
constructor() {
|
||||
this.windows = new Map(); // windowId -> { id, element, title, icon, minimized }
|
||||
this.zIndexCounter = 100;
|
||||
this.zIndexMax = 9999; // Prevent overflow
|
||||
this.eventBus = new EventEmitter();
|
||||
this.windowsContainer = null;
|
||||
this.taskbarWindows = null;
|
||||
this.commandPalette = null;
|
||||
|
||||
// App lifecycle hooks registry
|
||||
this.lifecycleHooks = {
|
||||
onWindowCreated: [],
|
||||
onWindowFocused: [],
|
||||
onWindowMinimized: [],
|
||||
onWindowRestored: [],
|
||||
onWindowClosed: []
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.windowsContainer = document.getElementById('windows-container');
|
||||
this.taskbarWindows = document.getElementById('taskbar-windows');
|
||||
|
||||
// Setup global event listeners
|
||||
this.setupGlobalListeners();
|
||||
|
||||
// Emit boot event
|
||||
this.eventBus.emit('os:boot', { timestamp: new Date().toISOString() });
|
||||
console.log('✅ BlackRoad OS initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global keyboard shortcuts and event listeners
|
||||
*/
|
||||
setupGlobalListeners() {
|
||||
// Close focused window on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const focusedWindow = this.getFocusedWindow();
|
||||
if (focusedWindow) {
|
||||
this.closeWindow(focusedWindow.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Command palette on Ctrl+K (future feature)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.toggleCommandPalette();
|
||||
}
|
||||
});
|
||||
|
||||
// Click on desktop to unfocus all windows
|
||||
document.getElementById('desktop')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'desktop' || e.target.classList.contains('desktop-icons')) {
|
||||
this.unfocusAllWindows();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a lifecycle hook
|
||||
* @param {string} hookName - onWindowCreated, onWindowFocused, etc.
|
||||
* @param {Function} callback - Function to call when event occurs
|
||||
*/
|
||||
registerLifecycleHook(hookName, callback) {
|
||||
if (this.lifecycleHooks[hookName]) {
|
||||
this.lifecycleHooks[hookName].push(callback);
|
||||
} else {
|
||||
console.warn(`Unknown lifecycle hook: ${hookName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all registered lifecycle hooks for an event
|
||||
* @param {string} hookName - The hook name
|
||||
* @param {Object} data - Data to pass to callbacks
|
||||
*/
|
||||
callLifecycleHooks(hookName, data) {
|
||||
if (this.lifecycleHooks[hookName]) {
|
||||
this.lifecycleHooks[hookName].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in lifecycle hook ${hookName}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new window or focus existing one
|
||||
* @param {Object} options - Window configuration
|
||||
* @param {string} options.id - Unique window identifier (recommended to match app ID)
|
||||
* @param {string} options.title - Window title
|
||||
* @param {string} options.icon - Icon emoji or HTML
|
||||
* @param {HTMLElement|string} options.content - Window content
|
||||
* @param {string} options.width - CSS width (default: '800px')
|
||||
* @param {string} options.height - CSS height (default: '600px')
|
||||
* @param {number} options.x - X position in pixels (optional, will center if not provided)
|
||||
* @param {number} options.y - Y position in pixels (optional, will center if not provided)
|
||||
* @param {HTMLElement} options.toolbar - Optional toolbar element
|
||||
* @param {HTMLElement} options.statusBar - Optional status bar element
|
||||
* @param {boolean} options.noPadding - Remove padding from content area
|
||||
* @returns {string} windowId
|
||||
*/
|
||||
createWindow(options) {
|
||||
const windowId = options.id || `window_${Date.now()}`;
|
||||
|
||||
// Window deduplication: if window already exists, focus it instead of creating duplicate
|
||||
if (this.windows.has(windowId)) {
|
||||
console.log(`🔄 Window "${windowId}" already exists - focusing existing instance`);
|
||||
const windowData = this.windows.get(windowId);
|
||||
|
||||
// If minimized, restore it
|
||||
if (windowData.minimized) {
|
||||
this.restoreWindow(windowId);
|
||||
} else {
|
||||
this.focusWindow(windowId);
|
||||
}
|
||||
|
||||
return windowId;
|
||||
}
|
||||
|
||||
// Create window element
|
||||
const windowEl = document.createElement('div');
|
||||
windowEl.className = 'os-window opening';
|
||||
windowEl.id = windowId;
|
||||
windowEl.setAttribute('role', 'dialog');
|
||||
windowEl.setAttribute('aria-label', options.title || 'Untitled Window');
|
||||
windowEl.style.width = options.width || '800px';
|
||||
windowEl.style.height = options.height || '600px';
|
||||
|
||||
// Position window: use provided coords or center with cascade offset
|
||||
if (options.x !== undefined && options.y !== undefined) {
|
||||
windowEl.style.left = `${options.x}px`;
|
||||
windowEl.style.top = `${options.y}px`;
|
||||
} else {
|
||||
// Center with cascade offset to avoid perfect stacking
|
||||
const offsetX = (this.windows.size * 30) % 100;
|
||||
const offsetY = (this.windows.size * 30) % 100;
|
||||
windowEl.style.left = `calc(50% - ${parseInt(options.width || 800) / 2}px + ${offsetX}px)`;
|
||||
windowEl.style.top = `calc(50% - ${parseInt(options.height || 600) / 2}px + ${offsetY}px)`;
|
||||
}
|
||||
|
||||
windowEl.style.zIndex = this.getNextZIndex();
|
||||
|
||||
// Create titlebar
|
||||
const titlebar = this.createTitlebar(windowId, options);
|
||||
windowEl.appendChild(titlebar);
|
||||
|
||||
// Toolbar (if provided)
|
||||
if (options.toolbar) {
|
||||
windowEl.appendChild(options.toolbar);
|
||||
}
|
||||
|
||||
// Window content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'window-content';
|
||||
if (options.noPadding) {
|
||||
content.classList.add('no-padding');
|
||||
}
|
||||
if (typeof options.content === 'string') {
|
||||
content.innerHTML = options.content;
|
||||
} else if (options.content instanceof HTMLElement) {
|
||||
content.appendChild(options.content);
|
||||
}
|
||||
windowEl.appendChild(content);
|
||||
|
||||
// Status bar (if provided)
|
||||
if (options.statusBar) {
|
||||
windowEl.appendChild(options.statusBar);
|
||||
}
|
||||
|
||||
// Make draggable
|
||||
this.makeDraggable(windowEl, titlebar);
|
||||
|
||||
// Add to container
|
||||
this.windowsContainer.appendChild(windowEl);
|
||||
|
||||
// Store window data
|
||||
this.windows.set(windowId, {
|
||||
id: windowId,
|
||||
element: windowEl,
|
||||
title: options.title,
|
||||
icon: options.icon,
|
||||
minimized: false
|
||||
});
|
||||
|
||||
// Add to taskbar
|
||||
this.addToTaskbar(windowId);
|
||||
|
||||
// Focus window
|
||||
this.focusWindow(windowId);
|
||||
|
||||
// Emit events
|
||||
this.eventBus.emit('window:created', { windowId, title: options.title });
|
||||
this.callLifecycleHooks('onWindowCreated', { windowId, title: options.title });
|
||||
|
||||
// Remove opening animation class after animation completes
|
||||
setTimeout(() => {
|
||||
windowEl.classList.remove('opening');
|
||||
}, 200);
|
||||
|
||||
console.log(`✨ Created window: "${options.title}" (${windowId})`);
|
||||
|
||||
return windowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create window titlebar with controls
|
||||
* @param {string} windowId - Window identifier
|
||||
* @param {Object} options - Window options
|
||||
* @returns {HTMLElement} Titlebar element
|
||||
*/
|
||||
createTitlebar(windowId, options) {
|
||||
const titlebar = document.createElement('div');
|
||||
titlebar.className = 'window-titlebar';
|
||||
|
||||
const titlebarLeft = document.createElement('div');
|
||||
titlebarLeft.className = 'window-titlebar-left';
|
||||
|
||||
if (options.icon) {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'window-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.innerHTML = options.icon;
|
||||
titlebarLeft.appendChild(icon);
|
||||
}
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'window-title';
|
||||
title.textContent = options.title || 'Untitled Window';
|
||||
titlebarLeft.appendChild(title);
|
||||
|
||||
titlebar.appendChild(titlebarLeft);
|
||||
|
||||
// Window controls
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'window-controls';
|
||||
controls.setAttribute('role', 'group');
|
||||
controls.setAttribute('aria-label', 'Window controls');
|
||||
|
||||
// Minimize button
|
||||
const minimizeBtn = document.createElement('button');
|
||||
minimizeBtn.className = 'window-control-btn minimize';
|
||||
minimizeBtn.innerHTML = '−';
|
||||
minimizeBtn.setAttribute('aria-label', 'Minimize window');
|
||||
minimizeBtn.title = 'Minimize';
|
||||
minimizeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.minimizeWindow(windowId);
|
||||
});
|
||||
|
||||
// Maximize button (stub for v0.2.0)
|
||||
const maximizeBtn = document.createElement('button');
|
||||
maximizeBtn.className = 'window-control-btn maximize';
|
||||
maximizeBtn.innerHTML = '□';
|
||||
maximizeBtn.setAttribute('aria-label', 'Maximize window (coming soon)');
|
||||
maximizeBtn.title = 'Maximize (coming in v0.2.0)';
|
||||
maximizeBtn.disabled = true; // Disabled until implemented
|
||||
maximizeBtn.style.opacity = '0.5';
|
||||
// TODO v0.2.0: Implement maximize functionality
|
||||
// Should toggle between normal and fullscreen (minus taskbar)
|
||||
// Store original size/position for restore
|
||||
// Add 'maximized' class and update button to restore icon
|
||||
|
||||
// Close button
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'window-control-btn close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close window');
|
||||
closeBtn.title = 'Close';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeWindow(windowId);
|
||||
});
|
||||
|
||||
controls.appendChild(minimizeBtn);
|
||||
controls.appendChild(maximizeBtn);
|
||||
controls.appendChild(closeBtn);
|
||||
|
||||
titlebar.appendChild(controls);
|
||||
|
||||
return titlebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make window draggable via titlebar
|
||||
* @param {HTMLElement} windowEl - Window element
|
||||
* @param {HTMLElement} handle - Drag handle (titlebar)
|
||||
*/
|
||||
makeDraggable(windowEl, handle) {
|
||||
let isDragging = false;
|
||||
let currentX;
|
||||
let currentY;
|
||||
let initialX;
|
||||
let initialY;
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
// Don't drag if clicking on buttons or other interactive elements
|
||||
if (e.target.classList.contains('window-control-btn') || e.target.tagName === 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
initialX = e.clientX - windowEl.offsetLeft;
|
||||
initialY = e.clientY - windowEl.offsetTop;
|
||||
|
||||
// Focus window when drag starts
|
||||
this.focusWindow(windowEl.id);
|
||||
|
||||
// Change cursor
|
||||
handle.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
currentX = e.clientX - initialX;
|
||||
currentY = e.clientY - initialY;
|
||||
|
||||
// Prevent dragging completely off-screen
|
||||
// Keep at least 100px of window visible
|
||||
const minVisible = 100;
|
||||
currentX = Math.max(-windowEl.offsetWidth + minVisible, Math.min(currentX, window.innerWidth - minVisible));
|
||||
currentY = Math.max(0, Math.min(currentY, window.innerHeight - 100));
|
||||
|
||||
windowEl.style.left = `${currentX}px`;
|
||||
windowEl.style.top = `${currentY}px`;
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
handle.style.cursor = 'move';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next z-index with overflow protection
|
||||
* @returns {number} Next z-index value
|
||||
*/
|
||||
getNextZIndex() {
|
||||
if (this.zIndexCounter >= this.zIndexMax) {
|
||||
// Reset z-index when we hit max, re-layer all windows
|
||||
console.log('🔄 Z-index overflow protection: resetting window layers');
|
||||
this.reindexWindows();
|
||||
}
|
||||
return this.zIndexCounter++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reindex all windows to prevent z-index overflow
|
||||
* Maintains relative stacking order
|
||||
*/
|
||||
reindexWindows() {
|
||||
const sortedWindows = Array.from(this.windows.values())
|
||||
.sort((a, b) => parseInt(a.element.style.zIndex) - parseInt(b.element.style.zIndex));
|
||||
|
||||
this.zIndexCounter = 100;
|
||||
sortedWindows.forEach(windowData => {
|
||||
windowData.element.style.zIndex = this.zIndexCounter++;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a window (bring to front)
|
||||
* @param {string} windowId - Window identifier
|
||||
*/
|
||||
focusWindow(windowId) {
|
||||
const windowData = this.windows.get(windowId);
|
||||
if (!windowData) {
|
||||
console.warn(`Cannot focus - window not found: ${windowId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update z-index to bring to front
|
||||
windowData.element.style.zIndex = this.getNextZIndex();
|
||||
|
||||
// Update visual states (only one window should be focused)
|
||||
this.windows.forEach((w, id) => {
|
||||
w.element.classList.toggle('focused', id === windowId);
|
||||
});
|
||||
|
||||
// Update taskbar button states
|
||||
this.updateTaskbar();
|
||||
|
||||
// Emit events
|
||||
this.eventBus.emit('window:focused', { windowId });
|
||||
this.callLifecycleHooks('onWindowFocused', { windowId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfocus all windows
|
||||
*/
|
||||
unfocusAllWindows() {
|
||||
this.windows.forEach(windowData => {
|
||||
windowData.element.classList.remove('focused');
|
||||
});
|
||||
this.updateTaskbar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize a window
|
||||
* @param {string} windowId - Window identifier
|
||||
*/
|
||||
minimizeWindow(windowId) {
|
||||
const windowData = this.windows.get(windowId);
|
||||
if (!windowData) return;
|
||||
|
||||
windowData.minimized = true;
|
||||
windowData.element.classList.add('minimized');
|
||||
|
||||
this.updateTaskbar();
|
||||
|
||||
// Emit events
|
||||
this.eventBus.emit('window:minimized', { windowId });
|
||||
this.callLifecycleHooks('onWindowMinimized', { windowId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a minimized window
|
||||
* @param {string} windowId - Window identifier
|
||||
*/
|
||||
restoreWindow(windowId) {
|
||||
const windowData = this.windows.get(windowId);
|
||||
if (!windowData) return;
|
||||
|
||||
windowData.minimized = false;
|
||||
windowData.element.classList.remove('minimized');
|
||||
|
||||
// Focus when restoring
|
||||
this.focusWindow(windowId);
|
||||
|
||||
// Emit events
|
||||
this.eventBus.emit('window:restored', { windowId });
|
||||
this.callLifecycleHooks('onWindowRestored', { windowId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a window
|
||||
* @param {string} windowId - Window identifier
|
||||
*/
|
||||
closeWindow(windowId) {
|
||||
const windowData = this.windows.get(windowId);
|
||||
if (!windowData) return;
|
||||
|
||||
const windowTitle = windowData.title;
|
||||
|
||||
// Emit events before removal (so apps can clean up)
|
||||
this.eventBus.emit('window:closed', { windowId, title: windowTitle });
|
||||
this.callLifecycleHooks('onWindowClosed', { windowId, title: windowTitle });
|
||||
|
||||
// Remove from DOM
|
||||
windowData.element.remove();
|
||||
|
||||
// Remove from windows map
|
||||
this.windows.delete(windowId);
|
||||
|
||||
// Update taskbar (will remove button)
|
||||
this.updateTaskbar();
|
||||
|
||||
console.log(`❌ Closed window: "${windowTitle}" (${windowId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add window to taskbar
|
||||
* @param {string} windowId - Window identifier
|
||||
*/
|
||||
addToTaskbar(windowId) {
|
||||
const windowData = this.windows.get(windowId);
|
||||
if (!windowData) return;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'taskbar-window-button';
|
||||
btn.id = `taskbar-${windowId}`;
|
||||
btn.textContent = windowData.title;
|
||||
btn.setAttribute('aria-label', `${windowData.title} window`);
|
||||
btn.setAttribute('role', 'button');
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
if (windowData.minimized) {
|
||||
this.restoreWindow(windowId);
|
||||
} else {
|
||||
// If already focused, minimize; otherwise focus
|
||||
if (windowData.element.classList.contains('focused')) {
|
||||
this.minimizeWindow(windowId);
|
||||
} else {
|
||||
this.focusWindow(windowId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation for taskbar buttons
|
||||
btn.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
btn.click();
|
||||
}
|
||||
});
|
||||
|
||||
this.taskbarWindows.appendChild(btn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update taskbar button states to match window states
|
||||
*/
|
||||
updateTaskbar() {
|
||||
this.windows.forEach((windowData, windowId) => {
|
||||
const btn = document.getElementById(`taskbar-${windowId}`);
|
||||
if (btn) {
|
||||
// Update active state
|
||||
btn.classList.toggle('active', windowData.element.classList.contains('focused'));
|
||||
|
||||
// Update visual opacity for minimized windows
|
||||
btn.style.opacity = windowData.minimized ? '0.6' : '1';
|
||||
|
||||
// Update ARIA state
|
||||
btn.setAttribute('aria-pressed', windowData.element.classList.contains('focused') ? 'true' : 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Remove buttons for closed windows
|
||||
this.taskbarWindows.querySelectorAll('.taskbar-window-button').forEach(btn => {
|
||||
const windowId = btn.id.replace('taskbar-', '');
|
||||
if (!this.windows.has(windowId)) {
|
||||
btn.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currently focused window
|
||||
* @returns {Object|null} Window data or null if no window is focused
|
||||
*/
|
||||
getFocusedWindow() {
|
||||
for (let [id, data] of this.windows) {
|
||||
if (data.element.classList.contains('focused')) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get window by ID
|
||||
* @param {string} windowId - Window identifier
|
||||
* @returns {Object|null} Window data or null
|
||||
*/
|
||||
getWindow(windowId) {
|
||||
return this.windows.get(windowId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open windows
|
||||
* @returns {Array} Array of window data objects
|
||||
*/
|
||||
getAllWindows() {
|
||||
return Array.from(this.windows.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle global command palette for unified search
|
||||
*/
|
||||
toggleCommandPalette() {
|
||||
if (!this.commandPalette) {
|
||||
this.buildCommandPalette();
|
||||
}
|
||||
const isVisible = this.commandPalette.classList.contains('open');
|
||||
if (isVisible) {
|
||||
this.commandPalette.classList.remove('open');
|
||||
} else {
|
||||
this.commandPalette.classList.add('open');
|
||||
const input = this.commandPalette.querySelector('input');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
this.populatePaletteResults('');
|
||||
}
|
||||
}
|
||||
|
||||
buildCommandPalette() {
|
||||
this.commandPalette = document.createElement('div');
|
||||
this.commandPalette.className = 'command-palette';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Search apps, notes, and knowledge (Ctrl/Cmd + K)';
|
||||
input.setAttribute('aria-label', 'Global search');
|
||||
this.commandPalette.appendChild(input);
|
||||
|
||||
const results = document.createElement('div');
|
||||
results.className = 'command-results';
|
||||
this.commandPalette.appendChild(results);
|
||||
|
||||
input.addEventListener('input', (e) => this.populatePaletteResults(e.target.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') this.toggleCommandPalette();
|
||||
});
|
||||
|
||||
document.body.appendChild(this.commandPalette);
|
||||
this.populatePaletteResults('');
|
||||
}
|
||||
|
||||
populatePaletteResults(query) {
|
||||
if (!this.commandPalette) return;
|
||||
const resultsContainer = this.commandPalette.querySelector('.command-results');
|
||||
resultsContainer.innerHTML = '';
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
const appMatches = Object.values(window.AppRegistry).filter(app =>
|
||||
app.name.toLowerCase().includes(lower) || app.description.toLowerCase().includes(lower)
|
||||
);
|
||||
|
||||
const captureMatches = (window.MockData?.captureItems || []).filter(item =>
|
||||
!query || (item.raw_content || '').toLowerCase().includes(lower)
|
||||
).slice(0, 5);
|
||||
|
||||
const projectMatches = (window.MockData?.creativeProjects || []).filter(project =>
|
||||
!query || project.title.toLowerCase().includes(lower)
|
||||
).slice(0, 5);
|
||||
|
||||
const renderGroup = (title, items, onClick) => {
|
||||
if (!items.length) return;
|
||||
const group = document.createElement('div');
|
||||
group.className = 'command-group';
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'command-group-title';
|
||||
heading.textContent = title;
|
||||
group.appendChild(heading);
|
||||
items.forEach(item => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'command-row';
|
||||
row.textContent = item.label;
|
||||
row.tabIndex = 0;
|
||||
row.addEventListener('click', () => onClick(item));
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') onClick(item);
|
||||
});
|
||||
group.appendChild(row);
|
||||
});
|
||||
resultsContainer.appendChild(group);
|
||||
};
|
||||
|
||||
renderGroup('Apps', appMatches.map(app => ({ label: `${app.icon} ${app.name}`, id: app.id })), (item) => {
|
||||
window.launchApp(item.id);
|
||||
this.toggleCommandPalette();
|
||||
});
|
||||
|
||||
renderGroup('Chaos Inbox', captureMatches.map(c => ({ label: `🌀 ${c.raw_content || c.type}`, id: 'chaos-inbox' })), (item) => {
|
||||
window.launchApp(item.id);
|
||||
this.toggleCommandPalette();
|
||||
});
|
||||
|
||||
renderGroup('Creator projects', projectMatches.map(p => ({ label: `🎨 ${p.title}`, id: 'creator-studio' })), (item) => {
|
||||
window.launchApp(item.id);
|
||||
this.toggleCommandPalette();
|
||||
});
|
||||
|
||||
if (!resultsContainer.childElementCount) {
|
||||
resultsContainer.textContent = 'No matches yet. Try searching for an app or project.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
* @param {Object} options - Notification options
|
||||
* @param {string} options.type - Notification type (success, error, warning, info)
|
||||
* @param {string} options.title - Notification title
|
||||
* @param {string} options.message - Notification message
|
||||
* @param {number} options.duration - Duration in ms (0 = persistent, default: 5000)
|
||||
*/
|
||||
showNotification(options) {
|
||||
const container = document.getElementById('notification-container');
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${options.type || 'info'}`;
|
||||
notification.setAttribute('role', 'alert');
|
||||
notification.setAttribute('aria-live', 'polite');
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'notification-header';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'notification-title';
|
||||
title.textContent = options.title || 'Notification';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'notification-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close notification');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
notification.remove();
|
||||
});
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'notification-body';
|
||||
body.textContent = options.message || '';
|
||||
|
||||
notification.appendChild(header);
|
||||
notification.appendChild(body);
|
||||
|
||||
container.appendChild(notification);
|
||||
|
||||
// Auto-remove after duration
|
||||
const duration = options.duration !== undefined ? options.duration : 5000;
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
this.eventBus.emit('notification:shown', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system diagnostics
|
||||
* @returns {Object} System diagnostics data
|
||||
*/
|
||||
getDiagnostics() {
|
||||
return {
|
||||
windowCount: this.windows.size,
|
||||
focusedWindowId: this.getFocusedWindow()?.id || null,
|
||||
zIndexCounter: this.zIndexCounter,
|
||||
eventBusListeners: Object.keys(this.eventBus.events).reduce((acc, key) => {
|
||||
acc[key] = this.eventBus.events[key].length;
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple Event Emitter for pub/sub communication
|
||||
* Enables loose coupling between OS and apps
|
||||
*/
|
||||
class EventEmitter {
|
||||
constructor() {
|
||||
this.events = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event listener
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} callback - Callback function
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
this.events[event].push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all listeners
|
||||
* @param {string} event - Event name
|
||||
* @param {*} data - Data to pass to listeners
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (!this.events[event]) return;
|
||||
this.events[event].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event listener for "${event}":`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event listener
|
||||
* @param {string} event - Event name
|
||||
* @param {Function} callback - Callback to remove
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.events[event]) return;
|
||||
this.events[event] = this.events[event].filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all listeners for an event
|
||||
* @param {string} event - Event name
|
||||
*/
|
||||
removeAllListeners(event) {
|
||||
if (event) {
|
||||
delete this.events[event];
|
||||
} else {
|
||||
this.events = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create global OS instance
|
||||
window.OS = new BlackRoadOS();
|
||||
Reference in New Issue
Block a user