feat(ui): 2.0.0-alpha.12 — editable Sacred Valley layout

Edit-layout mode: per-card resize (S/M/L), show/hide with a hidden-cards tray,
drag-to-reorder via a dedicated grip handle, and reset-to-default. Persists via
the existing /api/dashboard/layout (order/sizes/hidden) — no backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-04 18:15:08 +10:00
parent ce26895d8e
commit ae3a45251d
5 changed files with 165 additions and 27 deletions

View File

@@ -3,6 +3,10 @@
All notable changes to Void 2.0 are documented here. All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com). Format: [Keep a Changelog](https://keepachangelog.com).
## 2.0.0-alpha.12 — Editable Sacred Valley layout
- "Edit layout" mode on the dashboard: per-card **resize** (S/M/L column span), **show/hide** (with a hidden-cards tray to re-add), clearer **drag-to-reorder** via a dedicated grip handle, and a **Reset** to defaults.
- All changes persist through the existing `/api/dashboard/layout` (order/sizes/hidden) — no backend changes.
## 2.0.0-alpha.11 — DB-backed service registry + LAN auto-discovery ## 2.0.0-alpha.11 — DB-backed service registry + LAN auto-discovery
- The health-band registry is now in Postgres (`monitored_services`, migration 015) instead of the hand-edited `config/services.json` — which becomes a one-time boot seed (auto-populated if the table is empty). - The health-band registry is now in Postgres (`monitored_services`, migration 015) instead of the hand-edited `config/services.json` — which becomes a one-time boot seed (auto-populated if the table is empty).
- Owner CRUD over the registry: `POST/PATCH/DELETE /api/health/services` (add/edit/enable/disable/remove); `GET /api/health/services` is now DB-backed. - Owner CRUD over the registry: `POST/PATCH/DELETE /api/health/services` (add/edit/enable/disable/remove); `GET /api/health/services` is now DB-backed.

View File

@@ -1,6 +1,6 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.0.0-alpha.11", "version": "2.0.0-alpha.12",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -333,3 +333,36 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.disc-add { margin-left: auto; width: 22px; height: 22px; border-radius: 50%; flex: none; .disc-add { margin-left: auto; width: 22px; height: 22px; border-radius: 50%; flex: none;
border: 1px solid var(--accent-dim); background: transparent; color: var(--accent); font-size: 14px; line-height: 1; cursor: pointer; } border: 1px solid var(--accent-dim); background: transparent; color: var(--accent); font-size: 14px; line-height: 1; cursor: pointer; }
.disc-add:hover { background: var(--accent); color: var(--bg); } .disc-add:hover { background: var(--accent); color: var(--bg); }
/* ===== Sacred Valley — edit layout mode ===== */
.sv-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 22px; }
.sv-toolbar { display: flex; gap: 8px; flex: none; }
.sv-edit-btn, .sv-reset-btn { background: transparent; border: 1px solid var(--border); color: var(--text);
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-family: var(--font-ui); font-size: 12px; }
.sv-edit-btn:hover, .sv-reset-btn:hover { border-color: var(--accent-dim); color: var(--accent); }
.sv-card-title { cursor: default; } /* drag is via the grip now, not the title */
.sv-card-edit { position: absolute; top: 6px; right: 8px; z-index: 3; display: none; align-items: center; gap: 6px;
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
#sv-cards.editing .sv-card-edit { display: flex; }
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
.sv-grip { cursor: grab; color: var(--muted); font-size: 13px; padding: 0 2px; user-select: none; }
.sv-grip:active { cursor: grabbing; }
.sv-ed-sizes { display: flex; gap: 2px; }
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
border-radius: 3px; font-size: 10px; cursor: pointer; padding: 0; line-height: 1; }
.sv-ed-size { color: var(--muted); }
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
.sv-ed-hide { color: var(--bad); font-size: 11px; }
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
border: 1px dashed var(--border); border-radius: 8px; }
.sv-tray-label { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .14em; font-size: 10px; color: var(--muted); }
.sv-tray-chip { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 14px;
padding: 4px 10px; font-family: var(--font-ui); font-size: 11px; cursor: pointer; }
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }

View File

@@ -3,7 +3,7 @@ import { api } from '../api.js';
import { renderHealthBand, stopHealthBand } from './health_band.js'; import { renderHealthBand, stopHealthBand } from './health_band.js';
import { renderDevicesBand, stopDevicesBand } from './devices_band.js'; import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
import { svCard } from '../components/sv_card.js'; import { svCard } from '../components/sv_card.js';
import { attachReorder } from '../components/sv_reorder.js'; import { moveId } from '../components/sv_reorder.js';
import { orderCards } from './cards/registry.js'; import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js'; import clock from './cards/clock.js';
import weather from './cards/weather.js'; import weather from './cards/weather.js';
@@ -13,44 +13,145 @@ import inbox from './cards/inbox.js';
import search from './cards/search.js'; import search from './cards/search.js';
import speedtest from './cards/speedtest.js'; import speedtest from './cards/speedtest.js';
const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest]; // grows in later tasks const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest];
let active = []; // mounted cards needing stop() const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
let renderGen = 0; // guards against overlapping async renders
let active = []; // mounted cards needing stop()
let renderGen = 0; // guards overlapping async renders
let editing = false;
let layout = { card_order: [], hidden: [], sizes: {} };
const grid = () => document.getElementById('sv-cards');
async function saveLayout() {
try { await api.put('/api/dashboard/layout', layout); }
catch (e) { console.error('save layout', e); }
}
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const sizes = el('span', { class: 'sv-ed-sizes' },
...['s', 'm', 'l'].map(s =>
el('button', { class: 'sv-ed-size', dataset: { s }, onclick: () => setSize(def.id, s) }, s.toUpperCase())));
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
return el('div', { class: 'sv-card-edit' }, grip, sizes, hide);
}
function mountOne(def) {
const size = layout.sizes?.[def.id] || def.size;
const { root, body } = svCard({ ...def, size });
root.appendChild(editOverlay(def));
grid().appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
function setSize(id, s) {
layout.sizes = { ...layout.sizes, [id]: s };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) node.dataset.size = s;
saveLayout();
}
function hideCard(id) {
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
const def = BY_ID.get(id);
if (def?.stop) def.stop();
active = active.filter(d => d.id !== id);
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
renderTray();
saveLayout();
}
function showCard(id) {
layout.hidden = layout.hidden.filter(x => x !== id);
const def = BY_ID.get(id);
if (def) { mountOne(def); wireDrag(); }
renderTray();
saveLayout();
}
function onReorder(newOrder) {
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid().appendChild(frag);
layout.card_order = newOrder;
saveLayout();
}
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
function wireDrag() {
let dragId = null;
grid().querySelectorAll('.sv-card').forEach(card => {
if (card._wired) return; card._wired = true;
const g = card.querySelector('.sv-grip');
if (g) {
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
}
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', (e) => {
e.preventDefault(); card.classList.remove('drag-over');
if (!dragId || dragId === card.dataset.cardId) return;
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
onReorder(moveId(ids, dragId, card.dataset.cardId));
});
});
}
function renderTray() {
const tray = document.getElementById('sv-tray');
if (!tray) return;
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
mount(tray,
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
tray.style.display = editing ? 'flex' : 'none';
}
function toggleEdit() {
editing = !editing;
grid().classList.toggle('editing', editing);
const btn = document.getElementById('sv-edit-btn');
if (btn) btn.textContent = editing ? 'Done' : 'Edit layout';
const reset = document.getElementById('sv-reset-btn');
if (reset) reset.style.display = editing ? '' : 'none';
renderTray();
}
let mainEl;
async function resetLayout() {
layout = { card_order: [], hidden: [], sizes: {} };
await saveLayout();
render(mainEl);
}
export async function render(main) { export async function render(main) {
mainEl = main;
const myGen = ++renderGen; const myGen = ++renderGen;
active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand(); active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand();
editing = false;
mount(main, mount(main,
el('h1', { class: 'view-h1' }, 'Sacred Valley'), el('h1', { class: 'view-h1' }, 'Sacred Valley'),
el('p', { class: 'view-sub' }, 'The homelab, at a glance.'), el('div', { class: 'sv-head' },
el('p', { class: 'view-sub', style: { margin: 0 } }, 'The homelab, at a glance.'),
el('div', { class: 'sv-toolbar' },
el('button', { class: 'sv-reset-btn', id: 'sv-reset-btn', style: { display: 'none' }, onclick: resetLayout }, 'Reset'),
el('button', { class: 'sv-edit-btn', id: 'sv-edit-btn', onclick: toggleEdit }, 'Edit layout'))),
el('div', { id: 'sv-cards' }), el('div', { id: 'sv-cards' }),
el('div', { id: 'sv-tray', style: { display: 'none' } }),
el('div', { id: 'sv-health' }), el('div', { id: 'sv-health' }),
el('div', { id: 'sv-devices' }) el('div', { id: 'sv-devices' })
); );
let layout = { card_order: [], hidden: [], sizes: {} }; layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ } try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
// A newer render() started while we awaited — bail before mounting timers/DOM
// so we don't double-mount cards or leak intervals onto the live grid.
if (myGen !== renderGen) return; if (myGen !== renderGen) return;
const grid = document.getElementById('sv-cards'); for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
const ordered = orderCards(CARD_MODULES, layout); wireDrag();
for (const def of ordered) {
const size = layout.sizes?.[def.id] || def.size;
const { root, body } = svCard({ ...def, size });
grid.appendChild(root);
try { def.mount(body); def.start && def.start(); active.push(def); }
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
}
attachReorder(grid, async (newOrder) => {
// reflect immediately
const frag = document.createDocumentFragment();
newOrder.forEach(id => { const n = grid.querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
grid.appendChild(frag);
try { await api.put('/api/dashboard/layout', { ...layout, card_order: newOrder }); layout.card_order = newOrder; }
catch (e) { console.error('save layout', e); }
});
renderHealthBand(document.getElementById('sv-health')); renderHealthBand(document.getElementById('sv-health'));
renderDevicesBand(document.getElementById('sv-devices')); renderDevicesBand(document.getElementById('sv-devices'));
} }

View File

@@ -10,7 +10,7 @@ import { router as iconsRouter } from './lib/api/routes/icons.js';
import { startCron } from './lib/cron/index.js'; import { startCron } from './lib/cron/index.js';
import { seedFromConfig } from './lib/health/registry.js'; import { seedFromConfig } from './lib/health/registry.js';
const VERSION = '2.0.0-alpha.11'; const VERSION = '2.0.0-alpha.12';
export function createApp() { export function createApp() {
const app = express(); const app = express();