Initial commit
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
ui/node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
ui/dist/
|
||||||
|
ui/dist-ssr/
|
||||||
|
|
||||||
|
# Local configuration (optional, can be commented out if you want to track it)
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# sfmacro-client
|
||||||
|
|
||||||
|
`sfmacro-client` is a Node.js server that acts as a bridge between a custom Raspberry Pi Pico-based Macropad and your computer. It listens for physical button presses on the Pico over a USB Serial connection and executes corresponding configurable macros (such as simulating key presses, running commands, or toggling application audio). It also controls the RGB LEDs on the Macropad based on the button state or system metrics (e.g., CPU load).
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The architecture consists of three main components:
|
||||||
|
|
||||||
|
1. **Hardware (Raspberry Pi Pico)**
|
||||||
|
- The Pico is connected via USB and communicates over a Serial port.
|
||||||
|
- It sends serial messages when buttons are pressed (`P,<id>`) and released (`R,<id>`).
|
||||||
|
- It receives serial messages to set the RGB LED color for each button (`C,<id>,<r>,<g>,<b>`).
|
||||||
|
|
||||||
|
2. **Node.js Server (`server.js`)**
|
||||||
|
- The server auto-detects the connected Pico (by its Vendor ID `2E8A`) and maintains the serial connection.
|
||||||
|
- It stores the configuration in `config.json`.
|
||||||
|
- When a button press is detected, it triggers the configured action:
|
||||||
|
- **`key`**: Uses the `@nut-tree-fork/nut-js` library to simulate keyboard keystrokes or complex keyboard combos (e.g., `LeftControl + G`).
|
||||||
|
- **`cmd`**: Runs arbitrary system commands using Node's `child_process.exec`.
|
||||||
|
- **`mute_app`**: Toggles the mute state of specific applications (like `firefox.exe`) using the included `SoundVolumeView.exe` utility.
|
||||||
|
- **`cpu_monitor`**: Instead of triggering an action, a button assigned this action will constantly have its LED color updated based on the host computer's current CPU utilization (Green -> Yellow -> Orange -> Red) by polling with the `systeminformation` library.
|
||||||
|
- It hosts a local REST API and web server on port `3001` to allow configuration changes via a frontend UI.
|
||||||
|
|
||||||
|
3. **Web Interface (`ui/` folder)**
|
||||||
|
- A Vite/React-based frontend is bundled into the `ui/dist` folder and served by the Node application.
|
||||||
|
- It allows the user to easily configure each of the 16 buttons, setting their action type, command/key, idle LED color, and pressed LED color.
|
||||||
|
|
||||||
|
## Starting the Server
|
||||||
|
|
||||||
|
There are a couple of ways to launch the software depending on your needs:
|
||||||
|
|
||||||
|
- **Interactive Mode (`Start-Macropad.bat`)**: Starts the Node.js server, displays the console output, and automatically opens your default web browser to the `http://localhost:3001` configuration page.
|
||||||
|
- **Background Mode (`Run-Hidden.vbs`)**: Silently launches the Node server in the background without opening a visible command prompt window. This is ideal for adding to your Windows Startup folder for seamless background operation.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is saved automatically to `config.json` when updated via the web interface.
|
||||||
|
|
||||||
|
Each of the 16 buttons has the following properties:
|
||||||
|
- `actionType`: Can be `"key"`, `"cmd"`, `"mute_app"`, `"cpu_monitor"`, or `"none"`.
|
||||||
|
- `key`: The keystroke combination to send (e.g., `F20` or `LeftControl + G`). Used for the `key` action type.
|
||||||
|
- `cmd`: The command to execute or executable to mute. Used for the `cmd` and `mute_app` action types.
|
||||||
|
- `color`: The default RGB LED color when the button is idle.
|
||||||
|
- `pressedColor`: The RGB LED color when the button is actively pressed down.
|
||||||
4
Run-Hidden.vbs
Normal file
4
Run-Hidden.vbs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||||
|
Set WshShell = CreateObject("WScript.Shell")
|
||||||
|
WshShell.CurrentDirectory = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||||
|
WshShell.Run "cmd /c node server.js", 0, False
|
||||||
BIN
SoundVolumeView.exe
Normal file
BIN
SoundVolumeView.exe
Normal file
Binary file not shown.
7
Start-Macropad.bat
Normal file
7
Start-Macropad.bat
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
echo Starting Macropad Control Center...
|
||||||
|
start "Macropad Server" /MIN node server.js
|
||||||
|
timeout /t 2 /nobreak >nul
|
||||||
|
start http://localhost:3001
|
||||||
|
exit
|
||||||
2692
package-lock.json
generated
Normal file
2692
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "sfmacro-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"serialport": "^13.0.0",
|
||||||
|
"systeminformation": "^5.31.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
294
server.js
Normal file
294
server.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
const { SerialPort } = require('serialport');
|
||||||
|
const { ReadlineParser } = require('@serialport/parser-readline');
|
||||||
|
const { keyboard, Key } = require('@nut-tree-fork/nut-js');
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const si = require('systeminformation');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'ui', 'dist')));
|
||||||
|
|
||||||
|
const CONFIG_FILE = path.join(__dirname, 'config.json');
|
||||||
|
|
||||||
|
const sseClients = [];
|
||||||
|
|
||||||
|
// Default configuration structure
|
||||||
|
let config = {
|
||||||
|
portPath: "auto",
|
||||||
|
buttons: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize 16 default buttons if empty
|
||||||
|
for(let i=0; i<16; i++) {
|
||||||
|
config.buttons[i] = {
|
||||||
|
actionType: "none", // "key", "cmd", "none"
|
||||||
|
key: "",
|
||||||
|
cmd: "",
|
||||||
|
color: { r: 0, g: 0, b: 50 }, // Dim blue
|
||||||
|
pressedColor: { r: 255, g: 0, b: 0 } // Red
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
try {
|
||||||
|
const savedConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||||
|
config = { ...config, ...savedConfig };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse config.json, resetting to default config.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let serialPort = null;
|
||||||
|
let parser = null;
|
||||||
|
let reconnectTimeout = null;
|
||||||
|
let isConnecting = false;
|
||||||
|
|
||||||
|
function normalizeKeyName(name) {
|
||||||
|
const mapping = {
|
||||||
|
'ctrl': 'LeftControl',
|
||||||
|
'control': 'LeftControl',
|
||||||
|
'leftcontrol': 'LeftControl',
|
||||||
|
'rightcontrol': 'RightControl',
|
||||||
|
'shift': 'LeftShift',
|
||||||
|
'leftshift': 'LeftShift',
|
||||||
|
'rightshift': 'RightShift',
|
||||||
|
'alt': 'LeftAlt',
|
||||||
|
'leftalt': 'LeftAlt',
|
||||||
|
'rightalt': 'RightAlt',
|
||||||
|
'win': 'LeftSuper',
|
||||||
|
'super': 'LeftSuper',
|
||||||
|
'cmd': 'LeftSuper',
|
||||||
|
'command': 'LeftSuper',
|
||||||
|
'leftsuper': 'LeftSuper',
|
||||||
|
'rightsuper': 'RightSuper',
|
||||||
|
'enter': 'Enter',
|
||||||
|
'escape': 'Escape',
|
||||||
|
'space': 'Space',
|
||||||
|
'tab': 'Tab',
|
||||||
|
'backspace': 'Backspace',
|
||||||
|
'delete': 'Delete',
|
||||||
|
'up': 'Up',
|
||||||
|
'down': 'Down',
|
||||||
|
'left': 'Left',
|
||||||
|
'right': 'Right'
|
||||||
|
};
|
||||||
|
const norm = name.trim().toLowerCase();
|
||||||
|
if (mapping[norm]) return mapping[norm];
|
||||||
|
|
||||||
|
if (norm.length === 1) return norm.toUpperCase();
|
||||||
|
|
||||||
|
if (/^f\d+$/.test(norm)) {
|
||||||
|
return 'F' + norm.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.trim().charAt(0).toUpperCase() + name.trim().slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectToPico() {
|
||||||
|
if (isConnecting) return;
|
||||||
|
isConnecting = true;
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let portPath = config.portPath;
|
||||||
|
|
||||||
|
if (portPath === "auto") {
|
||||||
|
try {
|
||||||
|
const ports = await SerialPort.list();
|
||||||
|
// The Pico's Vendor ID is 2E8A. The Arduino core PID for USB Serial is 0005 (or similar depending on stack)
|
||||||
|
const picoPort = ports.find(p => p.vendorId && p.vendorId.toUpperCase() === '2E8A');
|
||||||
|
if (picoPort) {
|
||||||
|
portPath = picoPort.path;
|
||||||
|
console.log(`Found Raspberry Pi Pico on ${portPath}`);
|
||||||
|
} else {
|
||||||
|
console.log("Could not auto-detect Raspberry Pi Pico. Retrying in 5 seconds...");
|
||||||
|
isConnecting = false;
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
isConnecting = false;
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialPort && serialPort.isOpen) {
|
||||||
|
serialPort.removeAllListeners('close');
|
||||||
|
serialPort.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
serialPort = new SerialPort({ path: portPath, baudRate: 115200 }, (err) => {
|
||||||
|
isConnecting = false;
|
||||||
|
if (err) {
|
||||||
|
console.log('Error opening port: ', err.message);
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Connected to Pico!");
|
||||||
|
|
||||||
|
// Sync all LEDs to their idle states
|
||||||
|
setTimeout(() => {
|
||||||
|
for(let i=0; i<16; i++) {
|
||||||
|
setLedColor(i, config.buttons[i].color.r, config.buttons[i].color.g, config.buttons[i].color.b);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
parser = serialPort.pipe(new ReadlineParser({ delimiter: '\n' }));
|
||||||
|
|
||||||
|
parser.on('data', async (line) => {
|
||||||
|
line = line.trim();
|
||||||
|
if (line.startsWith("P,")) {
|
||||||
|
const id = parseInt(line.split(',')[1]);
|
||||||
|
console.log(`[EVENT] Button ${id} Pressed`);
|
||||||
|
sseClients.forEach(c => c.write(`data: ${JSON.stringify({ type: 'press', id })}\n\n`));
|
||||||
|
setLedColor(id, config.buttons[id].pressedColor.r, config.buttons[id].pressedColor.g, config.buttons[id].pressedColor.b);
|
||||||
|
|
||||||
|
// Execute Macro
|
||||||
|
const btn = config.buttons[id];
|
||||||
|
if (btn.actionType === "key" && btn.key) {
|
||||||
|
try {
|
||||||
|
const parts = btn.key.split('+').map(p => p.trim());
|
||||||
|
const keysToPress = [];
|
||||||
|
let isCombo = true;
|
||||||
|
|
||||||
|
for (const p of parts) {
|
||||||
|
const normalized = normalizeKeyName(p);
|
||||||
|
if (Key[normalized] !== undefined) {
|
||||||
|
keysToPress.push(Key[normalized]);
|
||||||
|
} else {
|
||||||
|
isCombo = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCombo && keysToPress.length > 0) {
|
||||||
|
await keyboard.type(...keysToPress);
|
||||||
|
} else {
|
||||||
|
// If it's not a recognizable combo of Key properties, type it out as a raw string
|
||||||
|
await keyboard.type(btn.key);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error("Key Error", e); }
|
||||||
|
} else if (btn.actionType === "cmd" && btn.cmd) {
|
||||||
|
console.log(`Executing: ${btn.cmd}`);
|
||||||
|
exec(btn.cmd, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(`exec error: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (btn.actionType === "mute_app" && btn.cmd) {
|
||||||
|
console.log(`Toggling mute for: ${btn.cmd}`);
|
||||||
|
const svvPath = path.join(__dirname, 'SoundVolumeView.exe');
|
||||||
|
exec(`"${svvPath}" /Switch "${btn.cmd}"`, (error, stdout, stderr) => {
|
||||||
|
if (error) console.error(`Mute error: ${error}\n${stderr}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (line.startsWith("R,")) {
|
||||||
|
const id = parseInt(line.split(',')[1]);
|
||||||
|
console.log(`[EVENT] Button ${id} Released`);
|
||||||
|
sseClients.forEach(c => c.write(`data: ${JSON.stringify({ type: 'release', id })}\n\n`));
|
||||||
|
if (config.buttons[id].actionType !== "cpu_monitor") {
|
||||||
|
setLedColor(id, config.buttons[id].color.r, config.buttons[id].color.g, config.buttons[id].color.b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
serialPort.on('close', () => {
|
||||||
|
console.log("Port closed, reconnecting in 5s...");
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (!reconnectTimeout) {
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
isConnecting = false;
|
||||||
|
connectToPico();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLedColor(id, r, g, b) {
|
||||||
|
if (serialPort && serialPort.isOpen) {
|
||||||
|
serialPort.write(`C,${id},${r},${g},${b}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API for Frontend Configuration UI
|
||||||
|
app.get('/api/events', (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
sseClients.push(res);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
const index = sseClients.indexOf(res);
|
||||||
|
if (index !== -1) sseClients.splice(index, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/config', (req, res) => {
|
||||||
|
res.json(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/config', (req, res) => {
|
||||||
|
config = req.body;
|
||||||
|
saveConfig();
|
||||||
|
res.json({ success: true });
|
||||||
|
// Update colors immediately on the device
|
||||||
|
for(let i=0; i<16; i++) {
|
||||||
|
if (config.buttons[i].actionType !== "cpu_monitor") {
|
||||||
|
setLedColor(i, config.buttons[i].color.r, config.buttons[i].color.g, config.buttons[i].color.b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// CPU Polling Loop
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const hasCpuMonitor = Object.values(config.buttons).some(b => b.actionType === "cpu_monitor");
|
||||||
|
if (!hasCpuMonitor) return;
|
||||||
|
|
||||||
|
const load = await si.currentLoad();
|
||||||
|
const cpu = load.currentLoad; // 0 to 100
|
||||||
|
|
||||||
|
let r=0, g=0, b=0;
|
||||||
|
if (cpu < 30) { g = 150; } // Green
|
||||||
|
else if (cpu < 60) { r = 150; g = 150; } // Yellow
|
||||||
|
else if (cpu < 85) { r = 200; g = 50; } // Orange
|
||||||
|
else { r = 255; } // Red
|
||||||
|
|
||||||
|
for(let i=0; i<16; i++) {
|
||||||
|
if (config.buttons[i].actionType === "cpu_monitor") {
|
||||||
|
setLedColor(i, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CPU Poll Error", e);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
app.listen(3001, () => {
|
||||||
|
console.log("-----------------------------------------");
|
||||||
|
console.log("Macropad Server listening on port 3001");
|
||||||
|
console.log("-----------------------------------------");
|
||||||
|
connectToPico();
|
||||||
|
});
|
||||||
24
ui/.gitignore
vendored
Normal file
24
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
ui/README.md
Normal file
16
ui/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
21
ui/eslint.config.js
Normal file
21
ui/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Macropad Control Center</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2774
ui/package-lock.json
generated
Normal file
2774
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
ui/package.json
Normal file
29
ui/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.18.0",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ui/public/favicon.svg
Normal file
1
ui/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
ui/public/icons.svg
Normal file
24
ui/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
297
ui/src/App.css
Normal file
297
ui/src/App.css
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: radial-gradient(circle at bottom right, #111827, #0f172a, #000000);
|
||||||
|
color: #fff;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #ec4899);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-preview {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn {
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.1), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn.selected {
|
||||||
|
transform: scale(1.05);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn.physically-pressed {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-id {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-action {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
margin-top: 30px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 20px rgba(139, 92, 246, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 15px 25px rgba(139, 92, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Pickers Layout */
|
||||||
|
.color-pickers {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper input[type="color"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper input[type="color"]::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-wrapper input[type="color"]::-webkit-color-swatch {
|
||||||
|
border: 2px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hex-label {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key Recorder */
|
||||||
|
.recorder-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-btn {
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-btn.recording {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
animation: pulse-red 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recorder-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a855f7;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast Notification */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container.error {
|
||||||
|
border-left-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
277
ui/src/App.jsx
Normal file
277
ui/src/App.jsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
// Hex to RGB
|
||||||
|
const hexToRgb = (hex) => {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result ? {
|
||||||
|
r: parseInt(result[1], 16),
|
||||||
|
g: parseInt(result[2], 16),
|
||||||
|
b: parseInt(result[3], 16)
|
||||||
|
} : { r: 0, g: 0, b: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// RGB to Hex
|
||||||
|
const rgbToHex = (color) => {
|
||||||
|
if (!color) return "#000000";
|
||||||
|
const { r, g, b } = color;
|
||||||
|
const toHex = (c) => {
|
||||||
|
const hex = Math.max(0, Math.min(255, c)).toString(16);
|
||||||
|
return hex.length === 1 ? "0" + hex : hex;
|
||||||
|
};
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [selectedBtn, setSelectedBtn] = useState(null);
|
||||||
|
const [pressedBtns, setPressedBtns] = useState(new Set());
|
||||||
|
const [toast, setToast] = useState({ show: false, message: '', type: 'success' });
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get('/api/config').then(res => {
|
||||||
|
setConfig(res.data);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Could not fetch config from server.", err);
|
||||||
|
showToast("Could not connect to Macropad server", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
const evtSource = new EventSource('/api/events');
|
||||||
|
evtSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'press') {
|
||||||
|
setPressedBtns(prev => new Set(prev).add(data.id.toString()));
|
||||||
|
} else if (data.type === 'release') {
|
||||||
|
setPressedBtns(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(data.id.toString());
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => evtSource.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRecording || !selectedBtn) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsRecording(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeKeys = [];
|
||||||
|
if (e.ctrlKey) activeKeys.push("LeftControl");
|
||||||
|
if (e.shiftKey) activeKeys.push("LeftShift");
|
||||||
|
if (e.altKey) activeKeys.push("LeftAlt");
|
||||||
|
if (e.metaKey) activeKeys.push("LeftSuper");
|
||||||
|
|
||||||
|
let keyName = e.key;
|
||||||
|
if (keyName === 'Control' || keyName === 'Shift' || keyName === 'Alt' || keyName === 'Meta') {
|
||||||
|
// Only show modifiers so far
|
||||||
|
} else {
|
||||||
|
const keyMappings = {
|
||||||
|
' ': 'Space',
|
||||||
|
'ArrowUp': 'Up',
|
||||||
|
'ArrowDown': 'Down',
|
||||||
|
'ArrowLeft': 'Left',
|
||||||
|
'ArrowRight': 'Right',
|
||||||
|
};
|
||||||
|
if (keyMappings[keyName]) {
|
||||||
|
keyName = keyMappings[keyName];
|
||||||
|
} else if (keyName.length === 1) {
|
||||||
|
keyName = keyName.toUpperCase();
|
||||||
|
}
|
||||||
|
activeKeys.push(keyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeKeys.length > 0) {
|
||||||
|
const combo = activeKeys.join(' + ');
|
||||||
|
const newConfig = { ...config };
|
||||||
|
newConfig.buttons[selectedBtn].key = combo;
|
||||||
|
setConfig(newConfig);
|
||||||
|
|
||||||
|
if (e.key !== 'Control' && e.key !== 'Shift' && e.key !== 'Alt' && e.key !== 'Meta') {
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
|
}, [isRecording, selectedBtn, config]);
|
||||||
|
|
||||||
|
const showToast = (msg, type = 'success') => {
|
||||||
|
setToast({ show: true, message: msg, type });
|
||||||
|
setTimeout(() => setToast(prev => ({ ...prev, show: false })), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
axios.post('/api/config', config)
|
||||||
|
.then(res => {
|
||||||
|
showToast("Configuration saved & applied successfully!");
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
showToast("Failed to save configuration.", "error");
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) return <div className="loading">Loading configuration...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<header>
|
||||||
|
<h1>Macropad Control Center</h1>
|
||||||
|
<p>Your ultimate companion app for the Pico RGB Keypad</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div className="pad-preview">
|
||||||
|
<div className="grid">
|
||||||
|
{Object.keys(config.buttons).map((id) => {
|
||||||
|
const btn = config.buttons[id];
|
||||||
|
const isSelected = selectedBtn === id;
|
||||||
|
const isPressed = pressedBtns.has(id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`key-btn ${isSelected ? 'selected' : ''} ${isPressed ? 'physically-pressed' : ''}`}
|
||||||
|
style={{
|
||||||
|
boxShadow: `0 0 15px rgba(${isPressed ? btn.pressedColor.r : btn.color.r}, ${isPressed ? btn.pressedColor.g : btn.color.g}, ${isPressed ? btn.pressedColor.b : btn.color.b}, 0.8)`,
|
||||||
|
borderColor: `rgb(${isPressed ? btn.pressedColor.r : btn.color.r}, ${isPressed ? btn.pressedColor.g : btn.color.g}, ${isPressed ? btn.pressedColor.b : btn.color.b})`
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBtn(id);
|
||||||
|
setIsRecording(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="key-id">{parseInt(id) + 1}</span>
|
||||||
|
<span className="key-action">{btn.actionType === 'none' ? '' : (btn.actionType === 'key' ? btn.key : (btn.actionType === 'cpu_monitor' ? 'CPU' : 'CMD'))}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button className="save-btn" onClick={handleSave}>Apply & Save Config</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBtn && (
|
||||||
|
<div className="editor-panel">
|
||||||
|
<h2>Edit Button {parseInt(selectedBtn) + 1}</h2>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Action Type</label>
|
||||||
|
<select
|
||||||
|
value={config.buttons[selectedBtn].actionType}
|
||||||
|
onChange={e => {
|
||||||
|
const newConfig = {...config};
|
||||||
|
newConfig.buttons[selectedBtn].actionType = e.target.value;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="key">Keystroke</option>
|
||||||
|
<option value="cmd">Run CLI Command</option>
|
||||||
|
<option value="mute_app">Toggle App Mute</option>
|
||||||
|
<option value="cpu_monitor">CPU Monitor (Dynamic LED)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.buttons[selectedBtn].actionType === 'key' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Key Binding</label>
|
||||||
|
<div className="recorder-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. A, Enter, Space..."
|
||||||
|
value={config.buttons[selectedBtn].key}
|
||||||
|
onChange={e => {
|
||||||
|
const newConfig = {...config};
|
||||||
|
newConfig.buttons[selectedBtn].key = e.target.value;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
disabled={isRecording}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`record-btn ${isRecording ? 'recording' : ''}`}
|
||||||
|
onClick={() => setIsRecording(!isRecording)}
|
||||||
|
>
|
||||||
|
{isRecording ? 'Listening...' : 'Record'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isRecording && <span className="recorder-tip">Press keys on your keyboard. Press ESC to cancel.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(config.buttons[selectedBtn].actionType === 'cmd' || config.buttons[selectedBtn].actionType === 'mute_app') && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{config.buttons[selectedBtn].actionType === 'mute_app' ? 'Application Name (.exe)' : 'Command to Execute'}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={config.buttons[selectedBtn].actionType === 'mute_app' ? 'e.g. firefox.exe, spotify.exe' : 'e.g. start spotify, pwsh -c ...'}
|
||||||
|
value={config.buttons[selectedBtn].cmd}
|
||||||
|
onChange={e => {
|
||||||
|
const newConfig = {...config};
|
||||||
|
newConfig.buttons[selectedBtn].cmd = e.target.value;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group color-pickers">
|
||||||
|
<div className="color-picker-item">
|
||||||
|
<label>Idle Color</label>
|
||||||
|
<div className="color-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={rgbToHex(config.buttons[selectedBtn].color)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const rgb = hexToRgb(e.target.value);
|
||||||
|
const newConfig = {...config};
|
||||||
|
newConfig.buttons[selectedBtn].color = rgb;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="hex-label">{rgbToHex(config.buttons[selectedBtn].color).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="color-picker-item">
|
||||||
|
<label>Pressed Color</label>
|
||||||
|
<div className="color-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={rgbToHex(config.buttons[selectedBtn].pressedColor)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const rgb = hexToRgb(e.target.value);
|
||||||
|
const newConfig = {...config};
|
||||||
|
newConfig.buttons[selectedBtn].pressedColor = rgb;
|
||||||
|
setConfig(newConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="hex-label">{rgbToHex(config.buttons[selectedBtn].pressedColor).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{toast.show && (
|
||||||
|
<div className={`toast-container ${toast.type}`}>
|
||||||
|
<div className="toast-message">{toast.message}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
ui/src/assets/hero.png
Normal file
BIN
ui/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
ui/src/assets/react.svg
Normal file
1
ui/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
ui/src/assets/vite.svg
Normal file
1
ui/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
105
ui/src/index.css
Normal file
105
ui/src/index.css
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
10
ui/src/main.jsx
Normal file
10
ui/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
7
ui/vite.config.js
Normal file
7
ui/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user