feat: device model, API, static UI, and endpoint tests
Made-with: Cursor
This commit is contained in:
251
src/static/devices.js
Normal file
251
src/static/devices.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// Device management: list, create, edit, delete (name and 6-byte address)
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'hex-addr-box';
|
||||
input.maxLength = 1;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-index', i);
|
||||
input.setAttribute('inputmode', 'numeric');
|
||||
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||
input.addEventListener('input', (e) => {
|
||||
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||
e.target.value = v;
|
||||
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||
e.target.nextElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||
e.target.previousElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||
boxes[j].value = pasted[j];
|
||||
}
|
||||
if (pasted.length > 0) {
|
||||
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||
boxes[nextIdx].focus();
|
||||
}
|
||||
});
|
||||
container.appendChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
function getAddressFromBoxes(container) {
|
||||
if (!container) return '';
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||
}
|
||||
|
||||
function setAddressToBoxes(container, addrStr) {
|
||||
if (!container) return;
|
||||
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
boxes.forEach((b, i) => {
|
||||
b.value = s[i] || '';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Failed to load devices');
|
||||
const devices = await response.json();
|
||||
renderDevicesList(devices || {});
|
||||
} catch (e) {
|
||||
console.error('loadDevicesModal:', e);
|
||||
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDevicesList(devices) {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'muted-text';
|
||||
p.textContent = 'No devices. Create one above.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
const addr = (dev && dev.address) ? dev.address : '—';
|
||||
meta.textContent = `Address: ${addr}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||
if (res.ok) await loadDevicesModal();
|
||||
else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || 'Delete failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Delete failed');
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
const modal = document.getElementById('edit-device-modal');
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
if (!modal || !idInput) return;
|
||||
idInput.value = devId;
|
||||
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function createDevice(name, address) {
|
||||
try {
|
||||
const res = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to create device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('createDevice:', e);
|
||||
alert('Failed to create device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDevice(devId, name, address) {
|
||||
try {
|
||||
const res = await fetch(`/devices/${devId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, address: address || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
await loadDevicesModal();
|
||||
return true;
|
||||
}
|
||||
alert(data.error || 'Failed to update device');
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('updateDevice:', e);
|
||||
alert('Failed to update device');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const devicesBtn = document.getElementById('devices-btn');
|
||||
const devicesModal = document.getElementById('devices-modal');
|
||||
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||
const newName = document.getElementById('new-device-name');
|
||||
const createBtn = document.getElementById('create-device-btn');
|
||||
const editForm = document.getElementById('edit-device-form');
|
||||
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
devicesModal.classList.add('active');
|
||||
loadDevicesModal();
|
||||
});
|
||||
}
|
||||
if (devicesCloseBtn) {
|
||||
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||
}
|
||||
const newAddressBoxes = document.getElementById('new-device-address-boxes');
|
||||
const doCreate = async () => {
|
||||
const name = (newName && newName.value.trim()) || '';
|
||||
if (!name) {
|
||||
alert('Device name is required.');
|
||||
return;
|
||||
}
|
||||
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
|
||||
const ok = await createDevice(name, address);
|
||||
if (ok && newName) {
|
||||
newName.value = '';
|
||||
setAddressToBoxes(newAddressBoxes, '');
|
||||
}
|
||||
};
|
||||
if (createBtn) createBtn.addEventListener('click', doCreate);
|
||||
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const devId = idInput && idInput.value;
|
||||
if (!devId) return;
|
||||
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||
const ok = await updateDevice(
|
||||
devId,
|
||||
nameInput ? nameInput.value.trim() : '',
|
||||
address
|
||||
);
|
||||
if (ok) editDeviceModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
if (editCloseBtn) {
|
||||
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user