Files
MP-Server/web/js/app.js
jknapp 5888aeb603 Modernize application to v0.9.0 with PySide6, FastAPI, and PWA support
## Major Changes

### Build System
- Replace requirements.txt with pyproject.toml for modern dependency management
- Support for uv package manager alongside pip
- Update PyInstaller spec files for new dependencies and structure

### Desktop GUI (Tkinter → PySide6)
- Complete rewrite of UI using PySide6/Qt6
- New modular structure in gui/ directory:
  - main_window.py: Main application window
  - macro_editor.py: Macro creation/editing dialog
  - command_builder.py: Visual command sequence builder
- Modern dark theme with consistent styling
- System tray integration

### Web Server (Flask → FastAPI)
- Migrate from Flask/Waitress to FastAPI/Uvicorn
- Add WebSocket support for real-time updates
- Full CRUD API for macro management
- Image upload endpoint

### Web Interface → PWA
- New web/ directory with standalone static files
- PWA manifest and service worker for installability
- Offline caching support
- Full macro editing from web interface
- Responsive mobile-first design
- Command builder UI matching desktop functionality

### Macro System Enhancement
- New command sequence model replacing simple text/app types
- Command types: text, key, hotkey, wait, app
- Support for delays between commands (wait in ms)
- Support for key presses between commands (enter, tab, etc.)
- Automatic migration of existing macros to new format
- Backward compatibility maintained

### Files Added
- pyproject.toml
- gui/__init__.py, main_window.py, macro_editor.py, command_builder.py
- gui/widgets/__init__.py
- web/index.html, manifest.json, service-worker.js
- web/css/styles.css, web/js/app.js
- web/icons/icon-192.png, icon-512.png

### Files Removed
- requirements.txt (replaced by pyproject.toml)
- ui_components.py (replaced by gui/ modules)
- web_templates.py (replaced by web/ static files)
- main.spec (consolidated into platform-specific specs)

### Files Modified
- main.py: Simplified entry point for PySide6
- macro_manager.py: Command sequence model and migration
- web_server.py: FastAPI implementation
- config.py: Version bump to 0.9.0
- All .spec files: Updated for PySide6 and new structure
- README.md: Complete rewrite for v0.9.0
- .gitea/workflows/release.yml: Disabled pending build testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:57:14 -08:00

482 lines
15 KiB
JavaScript

// MacroPad PWA Application
class MacroPadApp {
constructor() {
this.macros = {};
this.tabs = [];
this.currentTab = 'All';
this.ws = null;
this.editingMacroId = null;
this.commands = [];
this.init();
}
async init() {
await this.loadTabs();
await this.loadMacros();
this.setupWebSocket();
this.setupEventListeners();
this.checkInstallPrompt();
}
// API Methods
async loadTabs() {
try {
const response = await fetch('/api/tabs');
const data = await response.json();
this.tabs = data.tabs || [];
this.renderTabs();
} catch (error) {
console.error('Error loading tabs:', error);
this.showToast('Error loading tabs', 'error');
}
}
async loadMacros() {
try {
const url = this.currentTab === 'All'
? '/api/macros'
: `/api/macros/${encodeURIComponent(this.currentTab)}`;
const response = await fetch(url);
const data = await response.json();
this.macros = data.macros || {};
this.renderMacros();
} catch (error) {
console.error('Error loading macros:', error);
this.showToast('Error loading macros', 'error');
}
}
async executeMacro(macroId) {
try {
const card = document.querySelector(`[data-macro-id="${macroId}"]`);
if (card) card.classList.add('executing');
const response = await fetch('/api/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ macro_id: macroId })
});
if (!response.ok) throw new Error('Execution failed');
setTimeout(() => {
if (card) card.classList.remove('executing');
}, 300);
} catch (error) {
console.error('Error executing macro:', error);
this.showToast('Error executing macro', 'error');
}
}
async saveMacro() {
const name = document.getElementById('macro-name').value.trim();
const category = document.getElementById('macro-category').value.trim();
if (!name) {
this.showToast('Please enter a macro name', 'error');
return;
}
if (this.commands.length === 0) {
this.showToast('Please add at least one command', 'error');
return;
}
const macroData = {
name,
category,
commands: this.commands
};
try {
let response;
if (this.editingMacroId) {
response = await fetch(`/api/macros/${this.editingMacroId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(macroData)
});
} else {
response = await fetch('/api/macros', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(macroData)
});
}
if (!response.ok) throw new Error('Save failed');
this.closeModal();
await this.loadTabs();
await this.loadMacros();
this.showToast('Macro saved successfully', 'success');
} catch (error) {
console.error('Error saving macro:', error);
this.showToast('Error saving macro', 'error');
}
}
async deleteMacro(macroId) {
if (!confirm('Are you sure you want to delete this macro?')) return;
try {
const response = await fetch(`/api/macros/${macroId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Delete failed');
await this.loadTabs();
await this.loadMacros();
this.showToast('Macro deleted', 'success');
} catch (error) {
console.error('Error deleting macro:', error);
this.showToast('Error deleting macro', 'error');
}
}
// WebSocket
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.updateConnectionStatus(true);
};
this.ws.onclose = () => {
this.updateConnectionStatus(false);
setTimeout(() => this.setupWebSocket(), 3000);
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onerror = () => {
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket error:', error);
}
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'macro_created':
case 'macro_updated':
case 'macro_deleted':
this.loadTabs();
this.loadMacros();
break;
case 'executed':
const card = document.querySelector(`[data-macro-id="${data.macro_id}"]`);
if (card) {
card.classList.add('executing');
setTimeout(() => card.classList.remove('executing'), 300);
}
break;
}
}
updateConnectionStatus(connected) {
const dot = document.querySelector('.status-dot');
const text = document.querySelector('.connection-status span');
if (dot) {
dot.classList.toggle('connected', connected);
}
if (text) {
text.textContent = connected ? 'Connected' : 'Disconnected';
}
}
// Rendering
renderTabs() {
const container = document.getElementById('tabs-container');
if (!container) return;
container.innerHTML = this.tabs.map(tab => `
<button class="tab ${tab === this.currentTab ? 'active' : ''}"
data-tab="${tab}">${tab}</button>
`).join('');
}
renderMacros() {
const container = document.getElementById('macro-grid');
if (!container) return;
const macroEntries = Object.entries(this.macros);
if (macroEntries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No macros found</p>
<button class="btn btn-primary" onclick="app.openAddModal()">
Add Your First Macro
</button>
</div>
`;
return;
}
container.innerHTML = macroEntries.map(([id, macro]) => {
const imageSrc = macro.image_path
? `/api/image/${macro.image_path}`
: null;
const firstChar = macro.name.charAt(0).toUpperCase();
return `
<div class="macro-card" data-macro-id="${id}" onclick="app.executeMacro('${id}')">
<button class="macro-edit-btn" onclick="event.stopPropagation(); app.openEditModal('${id}')">
Edit
</button>
${imageSrc
? `<img src="${imageSrc}" alt="${macro.name}" class="macro-image" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''
}
<div class="macro-image-placeholder" ${imageSrc ? 'style="display:none"' : ''}>
${firstChar}
</div>
<span class="macro-name">${macro.name}</span>
</div>
`;
}).join('');
}
renderCommandList() {
const container = document.getElementById('command-list');
if (!container) return;
if (this.commands.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No commands added yet</p></div>';
return;
}
container.innerHTML = this.commands.map((cmd, index) => {
let displayValue = '';
switch (cmd.type) {
case 'text':
displayValue = cmd.value || '';
break;
case 'key':
displayValue = cmd.value || '';
break;
case 'hotkey':
displayValue = (cmd.keys || []).join(' + ');
break;
case 'wait':
displayValue = `${cmd.ms || 0}ms`;
break;
case 'app':
displayValue = cmd.command || '';
break;
}
return `
<div class="command-item" data-index="${index}">
<span class="command-type">${cmd.type}</span>
<span class="command-value">${displayValue}</span>
<div class="command-actions">
<button onclick="app.moveCommand(${index}, -1)">Up</button>
<button onclick="app.moveCommand(${index}, 1)">Down</button>
<button class="delete" onclick="app.removeCommand(${index})">X</button>
</div>
</div>
`;
}).join('');
}
// Command Builder
addCommand(type) {
let command = { type };
switch (type) {
case 'text':
const text = prompt('Enter text to type:');
if (!text) return;
command.value = text;
break;
case 'key':
const key = prompt('Enter key to press (e.g., enter, tab, escape, space):');
if (!key) return;
command.value = key.toLowerCase();
break;
case 'hotkey':
const keys = prompt('Enter key combination (comma separated, e.g., ctrl,c):');
if (!keys) return;
command.keys = keys.split(',').map(k => k.trim().toLowerCase());
break;
case 'wait':
const ms = prompt('Enter delay in milliseconds:');
if (!ms) return;
command.ms = parseInt(ms, 10) || 0;
break;
case 'app':
const appCmd = prompt('Enter application command:');
if (!appCmd) return;
command.command = appCmd;
break;
}
this.commands.push(command);
this.renderCommandList();
}
removeCommand(index) {
this.commands.splice(index, 1);
this.renderCommandList();
}
moveCommand(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= this.commands.length) return;
[this.commands[index], this.commands[newIndex]] =
[this.commands[newIndex], this.commands[index]];
this.renderCommandList();
}
// Modal
openAddModal() {
this.editingMacroId = null;
this.commands = [];
document.getElementById('macro-name').value = '';
document.getElementById('macro-category').value = '';
document.getElementById('modal-title').textContent = 'Add Macro';
document.getElementById('delete-btn').style.display = 'none';
this.renderCommandList();
document.getElementById('modal-overlay').style.display = 'flex';
}
async openEditModal(macroId) {
this.editingMacroId = macroId;
const macro = this.macros[macroId];
if (!macro) return;
document.getElementById('macro-name').value = macro.name || '';
document.getElementById('macro-category').value = macro.category || '';
document.getElementById('modal-title').textContent = 'Edit Macro';
document.getElementById('delete-btn').style.display = 'block';
this.commands = JSON.parse(JSON.stringify(macro.commands || []));
this.renderCommandList();
document.getElementById('modal-overlay').style.display = 'flex';
}
closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
this.editingMacroId = null;
this.commands = [];
}
// Event Listeners
setupEventListeners() {
// Tab clicks
document.getElementById('tabs-container')?.addEventListener('click', (e) => {
if (e.target.classList.contains('tab')) {
this.currentTab = e.target.dataset.tab;
this.renderTabs();
this.loadMacros();
}
});
// Modal close on overlay click
document.getElementById('modal-overlay')?.addEventListener('click', (e) => {
if (e.target.id === 'modal-overlay') {
this.closeModal();
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
}
// Toast notifications
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// PWA Install Prompt
checkInstallPrompt() {
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
this.showInstallBanner(deferredPrompt);
});
}
showInstallBanner(deferredPrompt) {
const banner = document.createElement('div');
banner.className = 'install-banner';
banner.innerHTML = `
<span>Install MacroPad for quick access</span>
<div>
<button onclick="app.installPWA()">Install</button>
<button class="dismiss" onclick="this.parentElement.parentElement.remove()">X</button>
</div>
`;
document.body.insertBefore(banner, document.body.firstChild);
this.deferredPrompt = deferredPrompt;
}
async installPWA() {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
if (outcome === 'accepted') {
document.querySelector('.install-banner')?.remove();
}
this.deferredPrompt = null;
}
// Refresh
refresh() {
this.loadTabs();
this.loadMacros();
}
}
// Initialize app
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new MacroPadApp();
});
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}