Files
Void-Homelab/public/views/sacred_valley.js
root ae3a45251d 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>
2026-06-04 18:15:08 +10:00

158 lines
6.3 KiB
JavaScript

import { el, mount } from '../dom.js';
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 { moveId } from '../components/sv_reorder.js';
import { orderCards } from './cards/registry.js';
import clock from './cards/clock.js';
import weather from './cards/weather.js';
import hostPerf from './cards/host_perf.js';
import jobs from './cards/jobs.js';
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];
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('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' })
);
layout = { card_order: [], hidden: [], sizes: {} };
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
if (myGen !== renderGen) return;
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
wireDrag();
renderHealthBand(document.getElementById('sv-health'));
renderDevicesBand(document.getElementById('sv-devices'));
}