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 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'; import aiUsage from './cards/ai_usage.js'; import cluster from './cards/cluster.js'; import storage from './cards/storage.js'; import backups from './cards/backups.js'; import { blankCard } from './cards/blank.js'; import { blackflameCard } from './cards/blackflame.js'; const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage]; const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d])); // ---- hybrid canvas geometry ---- // Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units // (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a // per-card `free` flag (or holding Alt while dragging) allows fractional // placement + overlap. Everything scales with board width, so x/w stay relative. const COLS = 12; const ROW_H = 28; // px per grid row const GUTTER = 12; // visual gap baked into each card's rendered size const SIZE_W = { s: 3, m: 4, l: 6 }; const SIZE_H = { s: 6, m: 8, l: 10 }; let active = []; // mounted cards needing stop() let renderGen = 0; // guards overlapping async renders let editing = false; let mainEl; let layout = { hidden: [], geom: {}, extras: [] }; const grid = () => document.getElementById('sv-cards'); function defFor(extra) { if (extra.type === 'blank') return blankCard(extra.id); if (extra.type === 'blackflame') return blackflameCard(extra.id); return null; } function visibleDefs() { const hidden = new Set(layout.hidden || []); const builtins = CARD_MODULES.filter(d => !hidden.has(d.id)); const extras = (layout.extras || []).map(defFor).filter(Boolean); return [...builtins, ...extras]; } function defaultSize(def) { if (def.type === 'blackflame') return { w: 6, h: 10 }; if (def.type === 'blank') return { w: 3, h: 4 }; return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 }; } function geomOf(def) { const g = (layout.geom || {})[def.id]; if (g) return g; const { w, h } = defaultSize(def); return { x: 0, y: 0, w, h }; } async function saveLayout() { try { await api.put('/api/dashboard/layout', { card_order: [], sizes: {}, hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || [] }); } catch (e) { console.error('save layout', e); } } // Backfill geometry for any visible card lacking it (first load / migration / // a newly-shipped built-in). New cards flow below whatever already has a spot. function autoPlaceMissing(defs) { const geom = { ...(layout.geom || {}) }; let baseY = 0; for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h); let cx = 0, cy = baseY, rowH = 0; for (const d of defs) { if (geom[d.id]) continue; const { w, h } = defaultSize(d); if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; } geom[d.id] = { x: cx, y: cy, w, h }; cx += w; rowH = Math.max(rowH, h); } layout.geom = geom; } function cellW() { return grid().clientWidth / COLS; } function applyGeom(node, g) { const cw = cellW(); node.style.position = 'absolute'; node.style.left = (g.x * cw) + 'px'; node.style.top = (g.y * ROW_H) + 'px'; node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px'; node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px'; node.style.zIndex = g.free ? 5 : 1; node.classList.toggle('free', !!g.free); } function fitBoard() { let max = 0; grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); }); grid().style.height = (max + GUTTER) + 'px'; } function relayout() { active.forEach(def => { const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`); if (n) applyGeom(n, geomOf(def)); }); fitBoard(); } // ---- drag / resize (pointer-based; snap unless free or Alt held) ---- function beginDrag(ev, def, mode) { if (!editing) return; ev.preventDefault(); ev.stopPropagation(); const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`); if (!node) return; const g = { ...geomOf(def) }; const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h }; node.classList.add('dragging'); const cw = cellW(); function moveTo(e) { const dxc = (e.clientX - start.px) / cw; const dyc = (e.clientY - start.py) / ROW_H; const freeNow = g.free || e.altKey; if (mode === 'move') { let nx = start.x + dxc, ny = start.y + dyc; if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); } g.x = Math.max(0, Math.min(COLS - g.w, nx)); g.y = Math.max(0, ny); } else { let nw = start.w + dxc, nh = start.h + dyc; if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); } g.w = Math.max(2, Math.min(COLS - g.x, nw)); g.h = Math.max(2, nh); } applyGeom(node, g); fitBoard(); } function end() { document.removeEventListener('pointermove', moveTo); document.removeEventListener('pointerup', end); node.classList.remove('dragging'); layout.geom = { ...layout.geom, [def.id]: g }; applyGeom(node, g); saveLayout(); } document.addEventListener('pointermove', moveTo); document.addEventListener('pointerup', end); } function toggleFree(id) { const g = { ...(layout.geom[id] || geomOf({ id })) }; g.free = !g.free; layout.geom = { ...layout.geom, [id]: g }; const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) applyGeom(n, g); saveLayout(); } function editOverlay(def) { const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿'); grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move')); const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '⤢'); const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕'); const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' }); resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize')); const frag = document.createDocumentFragment(); frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize); return frag; } function mountOne(def) { const { root, body } = svCard(def); if (def.decorative) root.classList.add('sv-card-decor'); root.appendChild(editOverlay(def)); applyGeom(root, geomOf(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 placeNew(def) { let maxBottom = 0; for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); } const { w, h } = defaultSize(def); layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } }; } function addBuiltin(id) { layout.hidden = (layout.hidden || []).filter(x => x !== id); const def = BUILTIN_BY_ID.get(id); if (!def) return; placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout(); } function addDecor(type) { const id = `${type}-${Date.now().toString(36)}`; const def = defFor({ id, type }); if (!def) return; layout.extras = [...(layout.extras || []), { id, type }]; placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout(); } function removeCard(def) { const d = active.find(a => a.id === def.id); if (d && d.stop) d.stop(); active = active.filter(a => a.id !== def.id); grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove(); if (def.decorative) { layout.extras = (layout.extras || []).filter(e => e.id !== def.id); const g = { ...layout.geom }; delete g[def.id]; layout.geom = g; } else if (!(layout.hidden || []).includes(def.id)) { layout.hidden = [...(layout.hidden || []), def.id]; } renderTray(); fitBoard(); saveLayout(); } function renderTray() { const tray = document.getElementById('sv-tray'); if (!tray) return; const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean); mount(tray, el('span', { class: 'sv-tray-label' }, 'Add card:'), el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'), el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'), hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null, ...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)), el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap')); 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(); } async function resetLayout() { layout = { hidden: [], geom: {}, extras: [] }; 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 = { hidden: [], geom: {}, extras: [] }; try { const l = await api.get('/api/dashboard/layout'); layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] }; } catch { /* defaults */ } if (myGen !== renderGen) return; const defs = visibleDefs(); autoPlaceMissing(defs); for (const def of defs) mountOne(def); relayout(); renderTray(); window.removeEventListener('resize', relayout); window.addEventListener('resize', relayout); renderHealthBand(document.getElementById('sv-health')); renderDevicesBand(document.getElementById('sv-devices')); }