wip: frontend
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s

This commit is contained in:
2025-12-16 22:58:39 +01:00
parent b20a31ccf4
commit 2fa29235d1

View File

@@ -45,6 +45,71 @@
padding-left: 10px;
margin-top: 10px;
}
/* Tree view */
.tree ul {
list-style: none;
padding-left: 1rem;
}
.tree li {
margin: 2px 0;
}
.caret {
cursor: pointer;
display: inline-block;
width: 1em;
height: 1em;
user-select: none;
}
.caret::before {
content: "▶";
opacity: 0.6;
}
li.expanded > .caret::before {
content: "▼";
}
li:not(:has(ul)) > .caret::before {
content: "";
}
.node {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
user-select: none;
}
.node:hover {
background: #eee;
}
.node.selected {
background: #0078d7;
color: white;
}
.tree ul ul {
display: none;
}
li.expanded > ul {
display: block;
}
.scroll-tree-container {
border: 1px solid #ccc;
border-radius: 4px;
height: 300px;
overflow-y: auto;
overflow-x: hidden;
}
</style>
</head>
<body>
@@ -54,6 +119,7 @@
<nav>
<button onclick="showScreen('menu')">🏠 Main Menu</button>
<button onclick="showScreen('config')">⚙️ Config Editor</button>
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
</nav>
<!-- MAIN MENU -->
@@ -63,6 +129,7 @@
<ul>
<li><button onclick="showScreen('config')">Open Config Editor</button></li>
<li><button onclick="showScreen('playlist')">Open Playlist Editor</button></li>
<!-- More screens can be added later -->
</ul>
</div>
@@ -71,47 +138,117 @@
<div id="screen-config" class="screen">
<h2>Configuration Editor</h2>
<div id="config-container">Loading…</div>
<button id="save-btn" disabled>Save</button>
<button id="config-save-btn" disabled>Save</button>
</div>
<!-- PLAYLIST EDITOR SCREEN -->
<div id="screen-playlist" class="screen">
<h2>Playlist Editor</h2>
<div id="playlist-container">Loading…</div>
<div class="scroll-tree-container">
<div class="tree" id="tree">
<ul>
<li>
<span class="caret"></span>
<span class="node">Fruits</span>
<ul>
<li>
<span class="caret"></span>
<span class="node">Apple</span>
</li>
<li>
<span class="caret"></span>
<span class="node">Citrus</span>
<ul>
<li>
<span class="caret"></span>
<span class="node">Orange</span>
</li>
<li>
<span class="caret"></span>
<span class="node">Lemon</span>
</li>
</ul>
</li>
<li>
<span class="caret"></span>
<span class="node">Strawberry</span>
</li>
</ul>
</li>
</ul>
</div>
</div>
<button id="playlist-save-btn" disabled>Save</button>
</div>
<script>
/* -----------------------------
const Screens = {};
let activeScreen = null;
/* -----------------------------
Screen switching
----------------------------- */
function showScreen(name) {
----------------------------- */
function showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + name).classList.add('active');
if (name === "config") {
loadConfig(); // refresh most up-to-date config
activeScreen = name;
Screens[name]?.onShow?.();
}
}
/* -----------------------------
/* -----------------------------
CONFIG EDITOR LOGIC
----------------------------- */
async function loadConfig() {
----------------------------- */
Screens.config = (() => {
let screenroot = null;
function init() {
screenroot = document.getElementById('screen-config');
document.getElementById('config-save-btn').addEventListener('click', async () => {
const res = await fetch('/api/v1/config');
const original = await res.json();
const updated = serialize(original);
try {
const saveRes = await fetch('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated, null, 2)
});
if (!saveRes.ok) {
alert("Failed to save config: " + await saveRes.text());
return;
}
alert("Configuration saved successfully!");
} catch (err) {
alert("Error saving configuration: " + err);
}
});
}
async function onShow() {
const container = document.getElementById('config-container');
container.innerHTML = "Loading…";
document.getElementById('save-btn').disabled = true;
document.getElementById('config-save-btn').disabled = true;
try {
const res = await fetch('/api/v1/config');
const config = await res.json();
renderConfigForm(config);
render(config);
} catch (err) {
container.innerHTML = "Failed to load config: " + err;
}
}
}
function renderConfigForm(config) {
function render(config) {
const container = document.getElementById('config-container');
container.innerHTML = "";
container.appendChild(renderObject(config, "root"));
document.getElementById('save-btn').disabled = false;
}
document.getElementById('config-save-btn').disabled = false;
}
const config_names = {
const config_names = {
'root.IDLE_TIMEOUT_SECS': 'Idle Timeout (seconds)',
'root.BUTTON_MAP': 'Button map',
'root.BUTTON_MAP.NEXT': 'Next track',
@@ -122,8 +259,8 @@ const config_names = {
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
'root.TAGMODE': 'Tag mode',
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain'
};
const config_input_override = {
};
const config_input_override = {
'root.TAGMODE': {
'element': 'select',
'values': {
@@ -140,9 +277,9 @@ const config_input_override = {
'root.LED_COUNT': {
'input-type': 'number'
},
};
};
function renderObject(obj, path) {
function renderObject(obj, path) {
const wrapper = document.createElement('div');
Object.entries(obj).forEach(([key, value]) => {
@@ -195,10 +332,10 @@ function renderObject(obj, path) {
});
return wrapper;
}
}
function serializeConfig(rootObj) {
const inputs = document.querySelectorAll("input[data-path], select[data-path]");
function serialize(rootObj) {
const inputs = screenroot.querySelectorAll("input[data-path], select[data-path]");
inputs.forEach(input => {
const path = input.dataset.path.split('.').slice(1); // remove "root"
@@ -216,33 +353,97 @@ function serializeConfig(rootObj) {
});
return rootObj;
}
}
document.getElementById('save-btn').addEventListener('click', async () => {
const res = await fetch('/api/v1/config');
const original = await res.json();
const updated = serializeConfig(original);
return { init, onShow };
})();
/* -----------------------------
PLAYLIST EDITOR LOGIC
----------------------------- */
Screens.playlist = (() => {
let lastSelected = null;
function init() {
document.getElementById("tree").addEventListener("click", (e) => {
// CARET CLICK → expand/collapse
const caret = e.target.closest(".caret");
if (caret) {
const li = caret.parentElement;
if (li.querySelector("ul")) {
li.classList.toggle("expanded");
}
return; // IMPORTANT: don't affect selection
}
// NODE LABEL CLICK → selection only
const node = e.target.closest(".node");
if (!node) return;
if (e.shiftKey && lastSelected) {
selectRange(lastSelected, node);
} else if (e.ctrlKey || e.metaKey) {
node.classList.toggle("selected");
} else {
clearSelection();
node.classList.add("selected");
}
lastSelected = node;
});
}
async function onShow() {
const container = document.getElementById('playlist-container');
container.innerHTML = "Loading…";
document.getElementById('playlist-save-btn').disabled = true;
try {
const saveRes = await fetch('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated, null, 2)
const res = await fetch('/api/v1/playlist');
const config = await res.json();
renderPlaylistForm(config);
} catch (err) {
container.innerHTML = "Failed to load config: " + err;
}
}
function renderPlaylistForm(config) {
const container = document.getElementById('playlist-container');
container.innerHTML = "";
container.appendChild(renderPlaylistObject(config, "root"));
document.getElementById('playlist-save-btn').disabled = false;
}
function clearSelection() {
tree.querySelectorAll(".selected").forEach(n =>
n.classList.remove("selected")
);
}
function selectRange(start, end) {
const nodes = [...tree.querySelectorAll(".node")];
const startIndex = nodes.indexOf(start);
const endIndex = nodes.indexOf(end);
const [from, to] = startIndex < endIndex
? [startIndex, endIndex]
: [endIndex, startIndex];
clearSelection();
nodes.slice(from, to + 1).forEach(n =>
n.classList.add("selected")
);
}
return { init, onShow };
})();
// Initialization
Object.values(Screens).forEach(screen => {
screen.init?.();
});
if (!saveRes.ok) {
alert("Failed to save config: " + await saveRes.text());
return;
}
alert("Configuration saved successfully!");
} catch (err) {
alert("Error saving configuration: " + err);
}
});
// Load main menu by default
showScreen('menu');
showScreen("menu");
</script>
</body>