Files
Void-Homelab/public/views/sacred_valley.js
root f780043f2d feat(ui): 2.0.0-alpha.13 — finer per-card width scaling (12-col grid + -/+ stepper)
clock/weather etc. default to 1/6 width; sizes store an integer span 1-12
(legacy s/m/l still accepted by /api/dashboard/layout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:23:37 +10:00

183 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
function spanOf(def) {
const v = layout.sizes?.[def.id];
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
if (typeof v === 'string') return STR_SPAN[v] || 6;
return STR_SPAN[def.size] || 6;
}
function curSpan(id) {
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
}
function setSpan(id, delta) {
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
layout.sizes = { ...layout.sizes, [id]: span };
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
if (node) {
node.style.gridColumn = 'span ' + span;
const lbl = node.querySelector('.sv-span-val');
if (lbl) lbl.textContent = span;
}
saveLayout();
}
function editOverlay(def) {
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
const stepper = el('span', { class: 'sv-ed-span' },
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, ''),
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
}
function mountOne(def) {
const span = spanOf(def);
const { root, body } = svCard({ ...def, span });
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'));
}