From 2bf66ec5705bf60a948c79e7516586240541bd4d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:56:21 +1000 Subject: [PATCH] feat(devices): show icon + last-seen, icon picker in edit Co-Authored-By: Claude Opus 4.8 --- public/views/devices_band.js | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/public/views/devices_band.js b/public/views/devices_band.js index d73a7a0..eefe595 100644 --- a/public/views/devices_band.js +++ b/public/views/devices_band.js @@ -3,6 +3,8 @@ // newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band. import { el, mount, clear } from '../dom.js'; import { api } from '../api.js'; +import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js'; +import { iconPicker } from './icon_picker.js'; let host; const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; @@ -13,26 +15,50 @@ function tile(d) { clear(t); const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎'); edit.onclick = editMode; - mount(t, + const ref = d.icon || autoDefaultIcon(d.grp); + const src = resolveIcon(ref); + const img = el('img', { class: 'dv-icon', src, alt: '' }); + img.onerror = () => { + if (src && src.endsWith('.svg')) { img.src = src.replace(/\.svg$/, '.png'); return; } + img.replaceWith(el('div', { class: 'dv-icon-fb' }, (d.name?.[0] || '?').toUpperCase())); + }; + const seen = d.present === false && d.last_seen + ? el('span', { class: 'dv-seen' }, 'seen ' + relativeTime(d.last_seen)) : null; + mount(t, img, el('span', { class: 'dv-nm' }, d.name || 'Unknown'), el('span', { class: 'dv-ip' }, d.ip || ''), d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null, el('span', { class: 'dv-vendor' }, (d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')), + seen, d.mac ? edit : null); } function editMode() { clear(t); + let chosenIcon = d.icon || null; const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' }); const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g))); grpS.value = d.grp || 'Flagged'; + const pickerWrap = el('div', { class: 'dv-picker-wrap' }); + pickerWrap.style.display = 'none'; + const iconBtn = el('button', { class: 'ghost' }, 'Icon'); + iconBtn.onclick = () => { + if (pickerWrap.style.display === 'none') { + clear(pickerWrap); + pickerWrap.append(iconPicker(chosenIcon, ref => { chosenIcon = ref; iconBtn.textContent = 'Icon ✓'; pickerWrap.style.display = 'none'; })); + pickerWrap.style.display = 'block'; + } else pickerWrap.style.display = 'none'; + }; const save = el('button', { class: 'dv-add' }, 'Save'); - save.onclick = async () => { await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value }); load(); }; + save.onclick = async () => { + await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, icon: chosenIcon }); + load(); + }; const del = el('button', { class: 'ghost dv-ignore' }, 'Delete'); del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); }; const cancel = el('button', { class: 'ghost' }, 'Cancel'); cancel.onclick = view; - mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, save, del, cancel); + mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap); } view(); return t;