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>
158 lines
6.3 KiB
JavaScript
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'));
|
|
}
|