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:
@@ -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'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user