diff --git a/package-lock.json b/package-lock.json index 4c56f6c..fa7a5b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "void-server", - "version": "2.6.3", + "version": "2.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "void-server", - "version": "2.6.3", + "version": "2.6.4", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@mozilla/readability": "^0.6.0", diff --git a/package.json b/package.json index ae3530f..19a2c95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.6.3", + "version": "2.6.4", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index d363dc9..1a0c746 100644 --- a/public/style.css +++ b/public/style.css @@ -574,6 +574,16 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dv-tile:hover .dv-edit-btn { opacity: 1; } /* touch devices have no hover — keep the ✎ edit button always visible there */ @media (hover: none) { .dv-edit-btn { opacity: .85; } } +/* Little Blue service-tile edit affordance */ +.lb-tile-wrap { position: relative; } +.lb-edit-btn { position: absolute; top: 5px; right: 5px; z-index: 5; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; } +.lb-tile-wrap:hover .lb-edit-btn { opacity: 1; } +.lb-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); } +@media (hover: none) { .lb-edit-btn { opacity: .85; } } +.lb-edit { display: flex; flex-direction: column; gap: 4px; padding: 8px; } +.lb-edit .dv-edit-name, .lb-edit .dv-edit-grp { width: 100%; margin: 0; } +.lb-edit-btns { display: flex; gap: 4px; margin-top: 2px; } +.lb-edit-btns button { font-size: 11px; padding: 2px 8px; } .dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); } .dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; } .dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; } diff --git a/public/views/health_band.js b/public/views/health_band.js index 23a900f..e7263a9 100644 --- a/public/views/health_band.js +++ b/public/views/health_band.js @@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js'; import { isRemoteHost } from './service_url.js'; const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' }; +const CATS = ['agents', 'infrastructure', 'media', 'other']; let host, timer, scanning = false; async function promote(id) { @@ -17,6 +18,36 @@ function scan() { setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s } +// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE. +function editForm(s) { + const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' }); + const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c]))); + catS.value = s.category || 'other'; + const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' }); + const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' }); + const save = el('button', { class: 'dv-add' }, 'Save'); + save.onclick = async () => { + const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() }; + const ic = iconI.value.trim(); if (ic) patch.icon = ic; + try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ } + }; + const del = el('button', { class: 'ghost dv-ignore' }, 'Delete'); + del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } }; + const cancel = el('button', { class: 'ghost' }, 'Cancel'); + cancel.onclick = load; + return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI, + el('div', { class: 'lb-edit-btns' }, save, del, cancel)); +} + +// A service tile wrapped with an ✎ edit button that swaps to the edit form. +function tileWithEdit(s, remote) { + const wrap = el('div', { class: 'lb-tile-wrap' }); + const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎'); + edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); }; + mount(wrap, serviceTile(s, remote), edit); + return wrap; +} + // Owner-only; returns a section element or null (skipped for non-owner / none). async function discoveredSection() { let cand; @@ -46,7 +77,7 @@ async function load() { el('span', { class: 'gname' }, TITLE[g.category] || g.category), el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`), el('span', { class: 'line' })), - el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote))))); + el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote))))); const disc = await discoveredSection(); mount(host, el('div', { class: 'lbwrap' }, littleblueAvatar(),