Replace masonry grid with an absolute-positioned 12-col canvas: drag to move, corner to resize, per-card free/overlap toggle (Alt = no-snap). Geometry persisted (migration 027: dashboard_layout.geom + extras). Two new addable decorative cards: blank spacer + animated blackflame (canvas particle flame). Old layout auto-migrates by flow-placement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
11 KiB
JavaScript
294 lines
11 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 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'));
|
|
}
|