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>
This commit is contained in:
546
web/css/styles.css
Normal file
546
web/css/styles.css
Normal file
@@ -0,0 +1,546 @@
|
||||
/* MacroPad PWA Styles */
|
||||
|
||||
:root {
|
||||
--bg-color: #2e2e2e;
|
||||
--fg-color: #ffffff;
|
||||
--highlight-color: #3e3e3e;
|
||||
--accent-color: #007acc;
|
||||
--button-bg: #505050;
|
||||
--button-hover: #606060;
|
||||
--tab-bg: #404040;
|
||||
--tab-selected: #007acc;
|
||||
--danger-color: #dc3545;
|
||||
--success-color: #28a745;
|
||||
--warning-color: #ffc107;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--fg-color);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background-color: var(--highlight-color);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: #0096ff;
|
||||
}
|
||||
|
||||
.header-btn.secondary {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
|
||||
.header-btn.secondary:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-color);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: var(--tab-bg);
|
||||
color: var(--fg-color);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--tab-selected);
|
||||
}
|
||||
|
||||
/* Macro Grid */
|
||||
.macro-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.macro-card {
|
||||
background: var(--button-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, background 0.2s;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.macro-card:hover {
|
||||
background: var(--button-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.macro-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.macro-card.executing {
|
||||
animation: pulse 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(0.95); background: var(--accent-color); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.macro-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.macro-image-placeholder {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--highlight-color);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.macro-name {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.macro-edit-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.macro-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.macro-card:hover .macro-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--highlight-color);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--bg-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg-color);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--bg-color);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--button-bg);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Command Builder */
|
||||
.command-list {
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
min-height: 100px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
background: var(--button-bg);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.command-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.command-type {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-value {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.command-actions button {
|
||||
background: var(--highlight-color);
|
||||
border: none;
|
||||
color: var(--fg-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.command-actions button:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
.command-actions button.delete {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.add-command-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.add-command-btn {
|
||||
background: var(--button-bg);
|
||||
border: none;
|
||||
color: var(--fg-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.add-command-btn:hover {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0096ff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* Status/Toast Messages */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--highlight-color);
|
||||
color: var(--fg-color);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--success-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Connection Status */
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--button-bg);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.macro-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.macro-card {
|
||||
padding: 0.75rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.macro-image,
|
||||
.macro-image-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-width: 100%;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Install Banner */
|
||||
.install-banner {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.install-banner button {
|
||||
background: white;
|
||||
color: var(--accent-color);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.install-banner .dismiss {
|
||||
background: transparent;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
BIN
web/icons/icon-192.png
Normal file
BIN
web/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
web/icons/icon-512.png
Normal file
BIN
web/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
91
web/index.html
Normal file
91
web/index.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#007acc">
|
||||
<meta name="description" content="Remote macro control for your desktop">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="MacroPad">
|
||||
|
||||
<title>MacroPad</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<h1>MacroPad</h1>
|
||||
<div class="header-actions">
|
||||
<div class="connection-status">
|
||||
<div class="status-dot"></div>
|
||||
<span>Disconnected</span>
|
||||
</div>
|
||||
<button class="header-btn secondary" onclick="app.refresh()">Refresh</button>
|
||||
<button class="header-btn" onclick="app.openAddModal()">+ Add</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tabs -->
|
||||
<nav class="tabs" id="tabs-container">
|
||||
<!-- Tabs rendered dynamically -->
|
||||
</nav>
|
||||
|
||||
<!-- Macro Grid -->
|
||||
<main class="macro-grid" id="macro-grid">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal-overlay" id="modal-overlay" style="display: none;">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Add Macro</h2>
|
||||
<button class="modal-close" onclick="app.closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="macro-name">Name</label>
|
||||
<input type="text" id="macro-name" placeholder="Macro name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="macro-category">Category (optional)</label>
|
||||
<input type="text" id="macro-category" placeholder="Category">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Commands</label>
|
||||
<div class="command-list" id="command-list">
|
||||
<div class="empty-state"><p>No commands added yet</p></div>
|
||||
</div>
|
||||
<div class="add-command-btns">
|
||||
<button class="add-command-btn" onclick="app.addCommand('text')">+ Text</button>
|
||||
<button class="add-command-btn" onclick="app.addCommand('key')">+ Key</button>
|
||||
<button class="add-command-btn" onclick="app.addCommand('hotkey')">+ Hotkey</button>
|
||||
<button class="add-command-btn" onclick="app.addCommand('wait')">+ Wait</button>
|
||||
<button class="add-command-btn" onclick="app.addCommand('app')">+ App</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-danger" id="delete-btn" style="display: none;"
|
||||
onclick="app.deleteMacro(app.editingMacroId); app.closeModal();">
|
||||
Delete
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="app.saveMacro()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
481
web/js/app.js
Normal file
481
web/js/app.js
Normal file
@@ -0,0 +1,481 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
25
web/manifest.json
Normal file
25
web/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "MacroPad Server",
|
||||
"short_name": "MacroPad",
|
||||
"description": "Remote macro control for your desktop",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2e2e2e",
|
||||
"theme_color": "#007acc",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"]
|
||||
}
|
||||
72
web/service-worker.js
Normal file
72
web/service-worker.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// MacroPad PWA Service Worker
|
||||
const CACHE_NAME = 'macropad-v1';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/static/css/styles.css',
|
||||
'/static/js/app.js',
|
||||
'/static/icons/icon-192.png',
|
||||
'/static/icons/icon-512.png',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Install event - cache assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('Caching app assets');
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache, fallback to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Always fetch API requests from network
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
|
||||
event.respondWith(fetch(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// For other requests, try cache first, then network
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request).then((networkResponse) => {
|
||||
// Cache successful responses
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Return offline fallback for navigation requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user