From ae3a45251dc6ed862185a0993c38c22c9b8924d2 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 18:15:08 +1000 Subject: [PATCH] =?UTF-8?q?feat(ui):=202.0.0-alpha.12=20=E2=80=94=20editab?= =?UTF-8?q?le=20Sacred=20Valley=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 + package.json | 2 +- public/style.css | 33 ++++++++ public/views/sacred_valley.js | 151 ++++++++++++++++++++++++++++------ server.js | 2 +- 5 files changed, 165 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 875e606..0003f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to Void 2.0 are documented here. 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 - 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. diff --git a/package.json b/package.json index f631d13..4346872 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.0.0-alpha.11", + "version": "2.0.0-alpha.12", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index 692b3c4..30a4243 100644 --- a/public/style.css +++ b/public/style.css @@ -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; 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); } + +/* ===== 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); } diff --git a/public/views/sacred_valley.js b/public/views/sacred_valley.js index 0c8c1df..45bce0e 100644 --- a/public/views/sacred_valley.js +++ b/public/views/sacred_valley.js @@ -3,7 +3,7 @@ import { api } from '../api.js'; import { renderHealthBand, stopHealthBand } from './health_band.js'; import { renderDevicesBand, stopDevicesBand } from './devices_band.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 clock from './cards/clock.js'; import weather from './cards/weather.js'; @@ -13,44 +13,145 @@ import inbox from './cards/inbox.js'; import search from './cards/search.js'; import speedtest from './cards/speedtest.js'; -const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest]; // grows in later tasks -let active = []; // mounted cards needing stop() -let renderGen = 0; // guards against overlapping async renders +const CARD_MODULES = [clock, weather, hostPerf, jobs, inbox, search, speedtest]; +const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d])); + +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) { + mainEl = main; const myGen = ++renderGen; active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand(); + editing = false; mount(main, 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-tray', style: { display: 'none' } }), el('div', { id: 'sv-health' }), 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 */ } - // 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; - const grid = document.getElementById('sv-cards'); - const ordered = orderCards(CARD_MODULES, layout); - 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); } - }); + for (const def of orderCards(CARD_MODULES, layout)) mountOne(def); + wireDrag(); renderHealthBand(document.getElementById('sv-health')); renderDevicesBand(document.getElementById('sv-devices')); } diff --git a/server.js b/server.js index 2032e5e..7a38e8d 100644 --- a/server.js +++ b/server.js @@ -10,7 +10,7 @@ import { router as iconsRouter } from './lib/api/routes/icons.js'; import { startCron } from './lib/cron/index.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() { const app = express();