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')); }