addons implemented
parent
432f28ba1e
commit
fd5a39db77
20
index.html
20
index.html
|
|
@ -37,6 +37,9 @@
|
|||
<button id="btn-projects" title="Toggle projects panel">
|
||||
<span class="icon">📂</span> Projects
|
||||
</button>
|
||||
<button id="btn-addons" title="Manage addons">
|
||||
<span class="icon">🔌</span> Addons
|
||||
</button>
|
||||
<span id="connection-status" class="status-disconnected">Disconnected</span>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -146,6 +149,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Addons manager overlay -->
|
||||
<div id="addons-overlay" class="hidden">
|
||||
<div id="addons-modal">
|
||||
<div class="addons-header">
|
||||
<h3>Addons</h3>
|
||||
<button id="addons-close" title="Close">×</button>
|
||||
</div>
|
||||
<p class="addons-description">Upload <code>.js</code> addon files to add new toolbox categories. Addons are saved in your browser.</p>
|
||||
<div class="addons-upload-row">
|
||||
<input type="file" id="addon-file-input" accept=".js" />
|
||||
<button id="addon-install-btn">Install</button>
|
||||
</div>
|
||||
<div id="addon-status" class="addons-status"></div>
|
||||
<ul id="addons-list" class="addons-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
// Example addon: adds a "My Robot" toolbox category with two custom blocks.
|
||||
//
|
||||
// To use: click "Addons" in the toolbar, upload this file, and the
|
||||
// "My Robot" category will appear in the toolbox.
|
||||
|
||||
// --- Block definitions ---
|
||||
api.Blockly.Blocks['robot_drive'] = {
|
||||
init() {
|
||||
this.appendDummyInput()
|
||||
.appendField('drive')
|
||||
.appendField(new api.Blockly.FieldDropdown([
|
||||
['forward', 'FORWARD'],
|
||||
['backward', 'BACKWARD'],
|
||||
['left', 'LEFT'],
|
||||
['right', 'RIGHT'],
|
||||
['stop', 'STOP'],
|
||||
]), 'DIRECTION');
|
||||
this.appendValueInput('SPEED')
|
||||
.setCheck('Number')
|
||||
.appendField('speed');
|
||||
this.setInputsInline(true);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(45);
|
||||
this.setTooltip('Drive the robot in a direction at a given speed');
|
||||
},
|
||||
};
|
||||
|
||||
api.Blockly.Blocks['robot_beep'] = {
|
||||
init() {
|
||||
this.appendValueInput('TIMES')
|
||||
.setCheck('Number')
|
||||
.appendField('beep');
|
||||
this.appendDummyInput()
|
||||
.appendField('times');
|
||||
this.setInputsInline(true);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(45);
|
||||
this.setTooltip('Make the robot beep');
|
||||
},
|
||||
};
|
||||
|
||||
// --- Code generators ---
|
||||
api.pythonGenerator.forBlock['robot_drive'] = function (block) {
|
||||
const dir = block.getFieldValue('DIRECTION');
|
||||
const speed = api.pythonGenerator.valueToCode(
|
||||
block, 'SPEED', api.pythonGenerator.ORDER_NONE
|
||||
) || '0';
|
||||
return `robot.drive("${dir.toLowerCase()}", ${speed})\n`;
|
||||
};
|
||||
|
||||
api.pythonGenerator.forBlock['robot_beep'] = function (block) {
|
||||
const times = api.pythonGenerator.valueToCode(
|
||||
block, 'TIMES', api.pythonGenerator.ORDER_NONE
|
||||
) || '1';
|
||||
return `robot.beep(${times})\n`;
|
||||
};
|
||||
|
||||
// --- Register toolbox category ---
|
||||
api.registerCategories([
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'My Robot',
|
||||
colour: '45',
|
||||
contents: [
|
||||
{
|
||||
kind: 'block',
|
||||
type: 'robot_drive',
|
||||
inputs: {
|
||||
SPEED: { shadow: { type: 'math_number', fields: { NUM: 100 } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'block',
|
||||
type: 'robot_beep',
|
||||
inputs: {
|
||||
TIMES: { shadow: { type: 'math_number', fields: { NUM: 3 } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
// h-bridge Motor Driver addon
|
||||
//
|
||||
// Adds a "h-bridge Motor" toolbox category with:
|
||||
// - motor_init: set up a motor with IN1 and IN2 pins
|
||||
// - motor_speed: set speed from -255 (full reverse) to +255 (full forward)
|
||||
// - motor_stop: stop a motor
|
||||
//
|
||||
// Generates device-appropriate MicroPython:
|
||||
// - ESP32 / RP2040: machine.Pin + machine.PWM
|
||||
// - micro:bit: pinN.write_analog()
|
||||
|
||||
// --- Block definitions ---
|
||||
|
||||
api.Blockly.Blocks['hbridge_motor_init'] = {
|
||||
init() {
|
||||
this.appendDummyInput()
|
||||
.appendField('init motor')
|
||||
.appendField(new api.Blockly.FieldDropdown([
|
||||
['A', 'A'],
|
||||
['B', 'B'],
|
||||
]), 'MOTOR')
|
||||
.appendField('IN1 pin')
|
||||
.appendField(new api.Blockly.FieldNumber(2, 0, 48, 1), 'IN1')
|
||||
.appendField('IN2 pin')
|
||||
.appendField(new api.Blockly.FieldNumber(3, 0, 48, 1), 'IN2');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(30);
|
||||
this.setTooltip('Initialize an h-bridge motor channel with two pins');
|
||||
},
|
||||
};
|
||||
|
||||
api.Blockly.Blocks['hbridge_motor_speed'] = {
|
||||
init() {
|
||||
this.appendDummyInput()
|
||||
.appendField('set motor')
|
||||
.appendField(new api.Blockly.FieldDropdown([
|
||||
['A', 'A'],
|
||||
['B', 'B'],
|
||||
]), 'MOTOR');
|
||||
this.appendValueInput('SPEED')
|
||||
.setCheck('Number')
|
||||
.appendField('speed');
|
||||
this.setInputsInline(true);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(30);
|
||||
this.setTooltip('Set motor speed: -255 (full reverse) to +255 (full forward)');
|
||||
},
|
||||
};
|
||||
|
||||
api.Blockly.Blocks['hbridge_motor_stop'] = {
|
||||
init() {
|
||||
this.appendDummyInput()
|
||||
.appendField('stop motor')
|
||||
.appendField(new api.Blockly.FieldDropdown([
|
||||
['A', 'A'],
|
||||
['B', 'B'],
|
||||
]), 'MOTOR');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(30);
|
||||
this.setTooltip('Stop an h-bridge motor (brake)');
|
||||
},
|
||||
};
|
||||
|
||||
// --- Helpers used by generators ---
|
||||
|
||||
function isMicrobit() {
|
||||
return api.getDeviceId() === 'microbit';
|
||||
}
|
||||
|
||||
function mbPin(pin) {
|
||||
var n = Math.max(0, Math.min(20, parseInt(pin, 10) || 0));
|
||||
return 'pin' + n;
|
||||
}
|
||||
|
||||
// --- Code generators ---
|
||||
|
||||
api.pythonGenerator.forBlock['hbridge_motor_init'] = function (block) {
|
||||
var motor = block.getFieldValue('MOTOR');
|
||||
var in1 = block.getFieldValue('IN1');
|
||||
var in2 = block.getFieldValue('IN2');
|
||||
var m = motor.toLowerCase();
|
||||
|
||||
if (isMicrobit()) {
|
||||
// micro:bit: no explicit init needed, just store pin numbers for later use.
|
||||
// We define variables so speed/stop blocks can reference them.
|
||||
api.pythonGenerator.definitions_['import_microbit'] = 'from microbit import *';
|
||||
api.pythonGenerator.definitions_['hb_motor_' + m + '_pins'] =
|
||||
'motor_' + m + '_in1 = ' + mbPin(in1) + '\n' +
|
||||
'motor_' + m + '_in2 = ' + mbPin(in2);
|
||||
api.pythonGenerator.definitions_['hb_set_motor_mb'] = [
|
||||
'def _hb_set_motor(p1, p2, speed):',
|
||||
' speed = max(-255, min(255, int(speed)))',
|
||||
' duty = abs(speed) * 4',
|
||||
' if speed > 0:',
|
||||
' p1.write_analog(duty)',
|
||||
' p2.write_analog(0)',
|
||||
' elif speed < 0:',
|
||||
' p1.write_analog(0)',
|
||||
' p2.write_analog(duty)',
|
||||
' else:',
|
||||
' p1.write_analog(0)',
|
||||
' p2.write_analog(0)',
|
||||
].join('\n');
|
||||
} else {
|
||||
// ESP32 / RP2040: machine.PWM
|
||||
api.pythonGenerator.definitions_['import_machine'] =
|
||||
'from machine import Pin, PWM';
|
||||
api.pythonGenerator.definitions_['hb_motor_' + m + '_in1'] =
|
||||
'motor_' + m + '_in1 = PWM(Pin(' + in1 + '), freq=1000, duty=0)';
|
||||
api.pythonGenerator.definitions_['hb_motor_' + m + '_in2'] =
|
||||
'motor_' + m + '_in2 = PWM(Pin(' + in2 + '), freq=1000, duty=0)';
|
||||
api.pythonGenerator.definitions_['hb_set_motor'] = [
|
||||
'def _hb_set_motor(pwm1, pwm2, speed):',
|
||||
' speed = max(-255, min(255, int(speed)))',
|
||||
' duty = abs(speed) * 4',
|
||||
' if speed > 0:',
|
||||
' pwm1.duty(duty)',
|
||||
' pwm2.duty(0)',
|
||||
' elif speed < 0:',
|
||||
' pwm1.duty(0)',
|
||||
' pwm2.duty(duty)',
|
||||
' else:',
|
||||
' pwm1.duty(0)',
|
||||
' pwm2.duty(0)',
|
||||
].join('\n');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
api.pythonGenerator.forBlock['hbridge_motor_speed'] = function (block) {
|
||||
var motor = block.getFieldValue('MOTOR');
|
||||
var m = motor.toLowerCase();
|
||||
var speed = api.pythonGenerator.valueToCode(
|
||||
block, 'SPEED', api.pythonGenerator.ORDER_NONE
|
||||
) || '0';
|
||||
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, ' + speed + ')\n';
|
||||
};
|
||||
|
||||
api.pythonGenerator.forBlock['hbridge_motor_stop'] = function (block) {
|
||||
var motor = block.getFieldValue('MOTOR');
|
||||
var m = motor.toLowerCase();
|
||||
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0)\n';
|
||||
};
|
||||
|
||||
// --- Register toolbox category ---
|
||||
|
||||
api.registerCategories([
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'H-Bridge Motor',
|
||||
colour: '30',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'hbridge_motor_init' },
|
||||
{
|
||||
kind: 'block',
|
||||
type: 'hbridge_motor_speed',
|
||||
inputs: {
|
||||
SPEED: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
|
||||
},
|
||||
},
|
||||
{ kind: 'block', type: 'hbridge_motor_stop' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import * as Blockly from 'blockly';
|
||||
import { pythonGenerator } from 'blockly/python';
|
||||
import {
|
||||
registerAddonCategories,
|
||||
clearAddonCategories,
|
||||
getAddonCategories,
|
||||
getDeviceId,
|
||||
} from '../devices/registry.js';
|
||||
|
||||
const STORAGE_KEY = 'esp32block_addons';
|
||||
|
||||
/** { name: string, source: string }[] — persisted addon scripts */
|
||||
function loadSavedAddons() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveAddonList(list) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function getInstalledAddons() {
|
||||
return loadSavedAddons();
|
||||
}
|
||||
|
||||
/**
|
||||
* The API object passed to every addon's init().
|
||||
* refreshToolbox is injected later by setRefreshCallback().
|
||||
*/
|
||||
let refreshToolboxFn = () => {};
|
||||
|
||||
export function setRefreshCallback(fn) {
|
||||
refreshToolboxFn = fn;
|
||||
}
|
||||
|
||||
function makeAddonApi() {
|
||||
return {
|
||||
Blockly,
|
||||
pythonGenerator,
|
||||
getDeviceId,
|
||||
registerCategories(categories) {
|
||||
registerAddonCategories(categories);
|
||||
refreshToolboxFn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an addon script string in the current page.
|
||||
* The script should be a self-contained function body or ES module-style code
|
||||
* that calls `init(api)` where api is the addon API.
|
||||
*
|
||||
* We wrap the source in a function that receives the api object.
|
||||
*/
|
||||
function runAddonSource(source) {
|
||||
const fn = new Function('api', source);
|
||||
fn(makeAddonApi());
|
||||
}
|
||||
|
||||
/** Load a single addon from its source string. */
|
||||
export function loadAddonFromSource(name, source) {
|
||||
runAddonSource(source);
|
||||
}
|
||||
|
||||
/** Add an addon from a File object, persist it, and run it. */
|
||||
export async function installAddonFromFile(file) {
|
||||
const source = await file.text();
|
||||
const name = file.name.replace(/\.js$/i, '');
|
||||
|
||||
const list = loadSavedAddons();
|
||||
const existing = list.findIndex(a => a.name === name);
|
||||
if (existing >= 0) list.splice(existing, 1);
|
||||
list.push({ name, source });
|
||||
saveAddonList(list);
|
||||
|
||||
loadAddonFromSource(name, source);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Remove an addon by name. Requires page reload to fully take effect. */
|
||||
export function removeAddon(name) {
|
||||
const list = loadSavedAddons().filter(a => a.name !== name);
|
||||
saveAddonList(list);
|
||||
}
|
||||
|
||||
/** Run all persisted addons (call once at startup). */
|
||||
export function loadAllSavedAddons() {
|
||||
clearAddonCategories();
|
||||
const list = loadSavedAddons();
|
||||
for (const addon of list) {
|
||||
try {
|
||||
loadAddonFromSource(addon.name, addon.source);
|
||||
} catch (err) {
|
||||
console.error(`[addon] Failed to load "${addon.name}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,16 +60,33 @@ export function canFlashInBrowser() {
|
|||
|
||||
// --- toolbox builder ---
|
||||
|
||||
const addonCategories = [];
|
||||
|
||||
export function registerAddonCategories(categories) {
|
||||
for (const cat of categories) {
|
||||
if (cat && cat.kind === 'category') addonCategories.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAddonCategories() {
|
||||
return addonCategories;
|
||||
}
|
||||
|
||||
export function clearAddonCategories() {
|
||||
addonCategories.length = 0;
|
||||
}
|
||||
|
||||
export function buildToolbox(deviceId) {
|
||||
const profile = devices[deviceId || currentId];
|
||||
if (!profile) return { kind: 'categoryToolbox', contents: [] };
|
||||
|
||||
return {
|
||||
kind: 'categoryToolbox',
|
||||
contents: [
|
||||
...profile.categories,
|
||||
{ kind: 'sep' },
|
||||
...builtinCategories,
|
||||
],
|
||||
};
|
||||
const contents = [...profile.categories];
|
||||
if (addonCategories.length) {
|
||||
contents.push({ kind: 'sep' });
|
||||
contents.push(...addonCategories);
|
||||
}
|
||||
contents.push({ kind: 'sep' });
|
||||
contents.push(...builtinCategories);
|
||||
|
||||
return { kind: 'categoryToolbox', contents };
|
||||
}
|
||||
|
|
|
|||
94
src/main.js
94
src/main.js
|
|
@ -9,6 +9,13 @@ import {
|
|||
canFlashInBrowser,
|
||||
buildToolbox,
|
||||
} from './devices/registry.js';
|
||||
import {
|
||||
setRefreshCallback,
|
||||
loadAllSavedAddons,
|
||||
installAddonFromFile,
|
||||
removeAddon,
|
||||
getInstalledAddons,
|
||||
} from './addons/loader.js';
|
||||
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
|
||||
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
|
||||
import { flashFirmware } from './serial/flasher.js';
|
||||
|
|
@ -19,6 +26,9 @@ import './style.css';
|
|||
|
||||
// ─── Blockly Workspace ───────────────────────────────────
|
||||
|
||||
// Load saved addons before building toolbox so their categories are included
|
||||
loadAllSavedAddons();
|
||||
|
||||
const workspace = Blockly.inject('blockly-div', {
|
||||
toolbox: buildToolbox(getDeviceId()),
|
||||
theme: Blockly.Themes.Dark,
|
||||
|
|
@ -28,6 +38,11 @@ const workspace = Blockly.inject('blockly-div', {
|
|||
renderer: 'zelos',
|
||||
});
|
||||
|
||||
// Now that workspace exists, set the refresh callback for addons
|
||||
setRefreshCallback(() => {
|
||||
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
||||
});
|
||||
|
||||
// ─── Live Code Preview ───────────────────────────────────
|
||||
|
||||
const codeOutput = document.getElementById('code-output');
|
||||
|
|
@ -132,7 +147,7 @@ function hideSendProgress() {
|
|||
deviceSelect.value = getDeviceId();
|
||||
deviceSelect.addEventListener('change', () => {
|
||||
setDeviceId(deviceSelect.value);
|
||||
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
||||
refreshToolbox();
|
||||
updateCodePreview();
|
||||
// Update Flash button tooltip/label based on device
|
||||
btnFlash.title = canFlashInBrowser()
|
||||
|
|
@ -143,6 +158,10 @@ btnFlash.title = canFlashInBrowser()
|
|||
? 'Flash MicroPython firmware'
|
||||
: 'Download firmware (drag to device)';
|
||||
|
||||
function refreshToolbox() {
|
||||
workspace.updateToolbox(buildToolbox(getDeviceId()));
|
||||
}
|
||||
|
||||
function setConnectedUI(connected) {
|
||||
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
|
||||
btnRun.disabled = !connected;
|
||||
|
|
@ -386,3 +405,76 @@ terminalInput.addEventListener('keydown', async (e) => {
|
|||
await writeString(text + '\r\n');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Addons Manager UI ──────────────────────────────────
|
||||
|
||||
const btnAddons = document.getElementById('btn-addons');
|
||||
const addonsOverlay = document.getElementById('addons-overlay');
|
||||
const addonsClose = document.getElementById('addons-close');
|
||||
const addonFileInput = document.getElementById('addon-file-input');
|
||||
const addonInstallBtn = document.getElementById('addon-install-btn');
|
||||
const addonStatus = document.getElementById('addon-status');
|
||||
const addonsList = document.getElementById('addons-list');
|
||||
|
||||
function renderAddonsList() {
|
||||
const addons = getInstalledAddons();
|
||||
addonsList.innerHTML = '';
|
||||
if (!addons.length) {
|
||||
addonsList.innerHTML = '<li class="addons-empty">No addons installed</li>';
|
||||
return;
|
||||
}
|
||||
for (const addon of addons) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'addons-item';
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'addons-item-name';
|
||||
nameSpan.textContent = addon.name;
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'addons-item-remove';
|
||||
removeBtn.textContent = 'Remove';
|
||||
removeBtn.title = 'Remove addon (reload required)';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
removeAddon(addon.name);
|
||||
renderAddonsList();
|
||||
addonStatus.textContent = `"${addon.name}" removed. Reload the page to apply.`;
|
||||
addonStatus.className = 'addons-status status-warn';
|
||||
});
|
||||
li.appendChild(nameSpan);
|
||||
li.appendChild(removeBtn);
|
||||
addonsList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
btnAddons.addEventListener('click', () => {
|
||||
addonsOverlay.classList.remove('hidden');
|
||||
renderAddonsList();
|
||||
addonStatus.textContent = '';
|
||||
addonStatus.className = 'addons-status';
|
||||
});
|
||||
|
||||
addonsClose.addEventListener('click', () => {
|
||||
addonsOverlay.classList.add('hidden');
|
||||
});
|
||||
|
||||
addonsOverlay.addEventListener('click', (e) => {
|
||||
if (e.target === addonsOverlay) addonsOverlay.classList.add('hidden');
|
||||
});
|
||||
|
||||
addonInstallBtn.addEventListener('click', async () => {
|
||||
const file = addonFileInput.files[0];
|
||||
if (!file) {
|
||||
addonStatus.textContent = 'Select a .js file first.';
|
||||
addonStatus.className = 'addons-status status-err';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const name = await installAddonFromFile(file);
|
||||
addonStatus.textContent = `"${name}" installed successfully!`;
|
||||
addonStatus.className = 'addons-status status-ok';
|
||||
renderAddonsList();
|
||||
addonFileInput.value = '';
|
||||
} catch (err) {
|
||||
addonStatus.textContent = `Error: ${err.message}`;
|
||||
addonStatus.className = 'addons-status status-err';
|
||||
}
|
||||
});
|
||||
|
|
|
|||
174
src/style.css
174
src/style.css
|
|
@ -82,6 +82,24 @@ html, body {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar-icon-btn {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.toolbar-icon-btn:hover {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -658,3 +676,159 @@ html, body {
|
|||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Addons Manager Overlay --- */
|
||||
#addons-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
#addons-modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.addons-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.addons-header h3 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.addons-header button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.addons-header button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addons-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.addons-description code {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.addons-upload-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addons-upload-row input[type="file"] {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addons-upload-row button {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.addons-upload-row button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-toolbar);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.addons-status {
|
||||
font-size: 12px;
|
||||
min-height: 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.addons-status.status-ok { color: var(--green); }
|
||||
.addons-status.status-err { color: var(--red); }
|
||||
.addons-status.status-warn { color: var(--yellow); }
|
||||
|
||||
.addons-list {
|
||||
list-style: none;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.addons-list .addons-empty {
|
||||
padding: 12px 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.addons-list .addons-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.addons-list .addons-item:hover {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.addons-item-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addons-item-remove {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--red);
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.addons-item-remove:hover {
|
||||
background: var(--red);
|
||||
color: var(--bg-toolbar);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue