Files
AtreidesAssetsFree/client/dashboard/dashboard.js
T

805 lines
31 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:890b0e46a8d4907e79564933d64186ce26573ba305bedb6fd4fa2f911700fadc
import { getPref, setPref } from '../data/preferences.js';
import {
activateProfile, activeProfile, createProfile, deleteProfile, listProfiles, listWidgets,
renameProfile, saveWidgets, widgetParams,
} from '../data/dashboard.js';
import * as dialog from '../ui/dialog.js';
import { DASHBOARD } from '../../shared/config.js';
import { t } from '../i18n/index.js';
import { CLOSE, PLUS, GRIP, ADJUST, PENCIL, TRASH } from '../ui/icons.js';
import { esc } from '../ui/escape.js';
import * as loading from '../ui/loading.js';
import { freeRunAt, fittingCatalog, placeInRun } from './free-space.js';
const LAYOUT_SCOPE = 'dashboard';
const MODEL_KEY = 'heightModel';
const LAYOUT_MODEL_KEY = 'layoutModel';
let masonryRO = null;
let masonryResizeBound = false;
let masonrySettleTimer = null;
let masonryRevealTimer = null;
let masonryMO = null;
let growCheckTimer = null;
function clampSpan(w) {
const n = Number(w);
if (!Number.isFinite(n)) return DASHBOARD.MIN_SPAN;
return Math.max(DASHBOARD.MIN_SPAN, Math.min(DASHBOARD.MAX_SPAN, Math.round(n)));
}
function clampH(h) {
const n = Number(h);
if (!Number.isFinite(n)) return DASHBOARD.MIN_H;
return Math.max(DASHBOARD.MIN_H, Math.min(DASHBOARD.MAX_H, Math.round(n)));
}
function stepPx() {
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return DASHBOARD.HEIGHT_STEP_REM * rootPx;
}
function pxForSteps(n, step, gap) { return n > 0 ? n * step + (n - 1) * gap : 0; }
function stepsForPx(px, step, gap) { return Math.max(1, Math.ceil((px + gap) / (step + gap))); }
function newInstance(c) {
const def = DASHBOARD.WIDGETS[c.type];
return { instId: c.instId, type: c.type, params: c.params || null, w: def.defaultSpan, h: 0, x: 0, y: 0 };
}
function cellSteps(it) {
if (it.h > 0) return clampH(it.h);
const def = DASHBOARD.WIDGETS[it.type];
return def && def.defaultH != null ? clampH(def.defaultH) : DASHBOARD.SKELETON_STEPS;
}
function spanSteps(it) { return Math.max(DASHBOARD.MIN_H, cellSteps(it)); }
function packLayout(list) {
const cols = DASHBOARD.GRID_COLUMNS;
const bottom = new Array(cols).fill(0);
for (const it of list) {
const w = clampSpan(it.w);
const h = Math.max(DASHBOARD.MIN_H, cellSteps(it));
const own = Math.max(0, Math.min(cols - w, Math.round(Number(it.x) || 0)));
let bestX = 0, bestY = Infinity;
for (let x = 0; x + w <= cols; x++) {
let y = 0;
for (let c = x; c < x + w; c++) if (bottom[c] > y) y = bottom[c];
if (y < bestY || (y === bestY && x === own)) { bestY = y; bestX = x; }
}
it.x = bestX; it.y = bestY;
for (let c = bestX; c < bestX + w; c++) bottom[c] = bestY + h;
}
return list;
}
function rectsOverlap(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + spanSteps(b) && b.y < a.y + spanSteps(a);
}
function makeRoom(list, anchors) {
const anchorSet = new Set(anchors);
const queue = anchors.slice();
let guard = 0;
while (queue.length && guard++ < 2000) {
const b = list[queue.shift()];
const bBottom = b.y + spanSteps(b);
for (let i = 0; i < list.length; i++) {
if (anchorSet.has(i)) continue;
const a = list[i];
if (a === b) continue;
if (rectsOverlap(a, b) && bBottom > a.y) { a.y = bBottom; queue.push(i); }
}
}
}
function layoutBottom(list) {
let bottom = 0;
for (const it of list) {
const b = (Math.max(0, Math.round(Number(it.y) || 0))) + Math.max(DASHBOARD.MIN_H, cellSteps(it));
if (b > bottom) bottom = b;
}
return bottom;
}
export async function readLayout() {
const list = await listWidgets((await activeProfile()).id);
const model = Number(await getPref(LAYOUT_SCOPE, MODEL_KEY, 0)) || 0;
const hinted = model >= DASHBOARD.HEIGHT_MODEL;
const layoutModel = Number(await getPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, 0)) || 0;
const placed = layoutModel >= DASHBOARD.LAYOUT_MODEL;
const out = list
.filter(r => DASHBOARD.WIDGETS[r.type])
.map(r => {
const def = DASHBOARD.WIDGETS[r.type];
const h = (hinted && r.spanHeight > 0) ? clampH(r.spanHeight) : 0;
const w = clampSpan(r.spanWidth != null ? r.spanWidth : def.defaultSpan);
return {
instId: String(r.instanceKey || r.type),
type: r.type,
params: widgetParams(r),
w,
h,
x: Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, Math.round(Number(r.gridX) || 0))),
y: placed ? Math.max(0, Math.round(Number(r.gridY) || 0)) : 0,
};
});
if (!placed) packLayout(out);
return out;
}
async function writeLayout(list) {
try {
const profileId = (await activeProfile()).id;
await saveWidgets(profileId, list.map(it => ({
instanceKey: it.instId, type: it.type, params: it.params || null,
gridX: it.x, gridY: it.y, spanWidth: it.w,
spanHeight: it.h > 0 ? it.h : null,
})));
await setPref(LAYOUT_SCOPE, LAYOUT_MODEL_KEY, DASHBOARD.LAYOUT_MODEL);
} catch { }
}
function errCard(e) {
return `<div class="card"><div class="t-body-sm t-muted">\u26A0 ${esc((e && e.message) || e)}</div></div>`;
}
function editBarHtml(it, i, hasSettings) {
let opts = '';
for (let n = DASHBOARD.MIN_SPAN; n <= DASHBOARD.MAX_SPAN; n++) opts += `<option value="${n}"${n === it.w ? ' selected' : ''}>${n}</option>`;
const widthSel = `<label class="dash-edit-wsel-wrap" title="${t('dashboard.width')}"><select class="dash-edit-wsel" data-act="width" data-i="${i}" aria-label="${t('dashboard.width')}">${opts}</select></label>`;
const cfgBtn = hasSettings
? `<button type="button" class="dash-edit-cfg" data-act="settings" data-i="${i}" title="${t('dashboard.settings')}" aria-label="${t('dashboard.settings')}">${ADJUST}</button>`
: '';
return `<div class="dash-edit-bar">
<span class="dash-edit-grip" data-drag="${i}" role="button" tabindex="-1" title="${t('dashboard.reorder')}" aria-label="${t('dashboard.reorder')}">${GRIP}</span>
${widthSel}
${cfgBtn}
<button type="button" class="dash-edit-x" data-act="remove" data-i="${i}" title="${t('dashboard.remove')}" aria-label="${t('dashboard.remove')}">${CLOSE}</button>
</div>`;
}
function cellHtml(it, i, edit, hasSettings) {
const x = Math.max(0, Math.round(Number(it.x) || 0));
const y = Math.max(0, Math.round(Number(it.y) || 0));
const span = spanSteps(it);
const cls = `dashboard-cell${it.h > 0 ? ' is-sized' : ''}`;
const place = `grid-column: ${x + 1} / span ${it.w}; grid-row: ${y + 1} / span ${span};`;
const settingsPanel = edit && hasSettings ? '<div class="dash-settings" data-settings hidden></div>' : '';
return `<div class="${cls}" data-cell="${i}" data-inst="${esc(it.instId)}" data-w="${it.w}" style="${place}">${edit ? editBarHtml(it, i, hasSettings) : ''}${settingsPanel}<div class="dashboard-body"></div></div>`;
}
async function profileListHtml() {
const profielen = await listProfiles();
const actief = profielen.find(p => p.isActive);
const systeem = !!(actief && actief.isSystem);
const items = profielen.map(p => `<button type="button" role="option" class="dash-profile-item${p.isActive ? ' is-active' : ''}"
aria-selected="${p.isActive ? 'true' : 'false'}" data-act="profile-pick" data-id="${p.id}">${esc(p.name)}</button>`).join('');
const knop = (act, icoon, label, uit = false) =>
`<button type="button" class="dash-profile-btn" data-act="${act}" title="${esc(label)}" aria-label="${esc(label)}"${uit ? ' disabled' : ''}>${icoon}</button>`;
return `<div class="dash-profiles">
<div class="dash-profile-list" role="listbox" aria-label="${esc(t('dashboard.profile'))}">${items}</div>
<div class="dash-profile-actions">
${knop('profile-new', PLUS, t('dashboard.profileNew'))}
${knop('profile-rename', PENCIL, systeem ? t('dashboard.profileSystemHint') : t('dashboard.profileRename'), systeem)}
${knop('profile-delete', TRASH, systeem ? t('dashboard.profileRestart') : t('dashboard.profileDelete'))}
</div>
</div>`;
}
const itemLabelHtml = c => c.labelHtml || esc(c.label);
async function toolsHtml() {
return profileListHtml();
}
function mobileAddHtml(catalog) {
const items = catalog.length
? catalog.map((c, i) => `<button type="button" class="dashboard-add-item" data-act="add" data-idx="${i}">${itemLabelHtml(c)}</button>`).join('')
: `<div class="dashboard-add-empty">${t('dashboard.addNone')}</div>`;
return `<div class="dash-add-mobile"><div class="t-label t-label--ter">${t('dashboard.add')}</div>${items}</div>`;
}
export async function renderDashboard(host, opts = {}) {
if (!host) return;
latestOpts.set(host, opts);
return draw(host, opts);
}
const latestOpts = new WeakMap();
const drawGen = new WeakMap();
const rerender = host => draw(host, latestOpts.get(host) || {});
async function draw(host, opts) {
const gen = (drawGen.get(host) || 0) + 1;
drawGen.set(host, gen);
const stale = () => drawGen.get(host) !== gen;
const renderers = opts.renderers || {};
const edit = !!opts.edit;
const toolbarHost = opts.toolbarHost || null;
const layout = await readLayout();
const catalog = edit && typeof opts.catalog === 'function' ? await opts.catalog(layout) : [];
if (stale()) return;
const hasSettings = type => !!(opts.settings && typeof opts.settings[type] === 'function');
teardownSettingsOutside();
const prevBodies = new Map();
host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(c => {
const b = c.querySelector('.dashboard-body');
if (b && c.dataset.inst) prevBodies.set(c.dataset.inst, b);
});
const freshMount = prevBodies.size === 0 && layout.length > 0;
const holdHeight = host.getBoundingClientRect().height;
if (holdHeight > 0) host.style.minHeight = holdHeight + 'px';
const order = layout.map((_, i) => i).sort((a, b) => (layout[a].y - layout[b].y) || (layout[a].x - layout[b].x));
const room = edit ? `<div class="dash-add-room" aria-hidden="true" style="grid-column: 1 / -1; grid-row: ${layoutBottom(layout) + 1} / span 2;"></div>` : '';
closeAddPop();
host.innerHTML = `<div class="dashboard-grid${edit ? ' is-editing' : ''}">${order.map(i => cellHtml(layout[i], i, edit, hasSettings(layout[i].type))).join('')}${room}</div>${edit ? mobileAddHtml(catalog) : ''}`;
const clickHandler = edit ? editClickHandler(host, layout, catalog, opts) : null;
if (toolbarHost) {
const tools = edit ? await toolsHtml() : '';
if (stale()) return;
toolbarHost.innerHTML = tools;
toolbarHost.onclick = edit ? clickHandler : null;
}
host.querySelectorAll('.dashboard-grid > .dashboard-cell').forEach(cell => {
const it = layout[Number(cell.dataset.cell)];
if (!it) return;
const render = renderers[it.type];
if (typeof render !== 'function') { cell.remove(); return; }
const prev = prevBodies.get(it.instId);
if (prev) { cell.querySelector('.dashboard-body').replaceWith(prev); prevBodies.delete(it.instId); return; }
const body = cell.querySelector('.dashboard-body');
try { render(body, it); }
catch (e) { body.innerHTML = errCard(e); }
});
host.onclick = clickHandler;
host.onchange = edit ? editChangeHandler(host, layout, opts) : null;
if (edit) setupDrag(host, layout, opts);
if (edit) setupAddBar(host, layout, catalog, opts);
teardownEditExit();
if (typeof opts.onEdit === 'function') {
if (edit) setupEditExit(host, opts);
else setupLongPress(host, opts);
}
if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; }
if (freshMount) {
const g = host.querySelector('.dashboard-grid');
if (g) {
g.__freshSettle = true;
g.classList.add('is-loading');
g.insertAdjacentHTML('beforebegin', skeletonHtml(layout));
loading.sizeSkeleton(g.parentElement);
masonryRevealTimer = setTimeout(() => revealGrid(g), DASHBOARD.REVEAL_MAX_MS);
}
}
setupMasonry(host, layout, opts);
if (holdHeight > 0) host.style.minHeight = '';
}
let settingsOutsideHandler = null;
function teardownSettingsOutside() {
if (settingsOutsideHandler) { document.removeEventListener('mousedown', settingsOutsideHandler, true); settingsOutsideHandler = null; }
}
function closeSettingsPanel(panel) {
if (!panel) return;
panel.setAttribute('hidden', '');
panel.innerHTML = '';
const cell = panel.closest('.dashboard-cell');
if (cell) cell.classList.remove('is-settings-open');
teardownSettingsOutside();
}
function editClickHandler(host, layout, catalog, opts) {
return async e => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'settings') {
const cell = btn.closest('.dashboard-cell');
const panel = cell && cell.querySelector('.dash-settings');
if (!panel) return;
const wasOpen = !panel.hasAttribute('hidden');
host.querySelectorAll('.dash-settings:not([hidden])').forEach(p => { if (p !== panel) closeSettingsPanel(p); });
if (wasOpen) { closeSettingsPanel(panel); return; }
const it = layout[Number(btn.dataset.i)];
const fn = it && opts.settings && opts.settings[it.type];
if (typeof fn !== 'function') return;
const commit = (o = {}) => {
if (o && o.resize) it.h = 0;
writeLayout(layout);
const body = cell.querySelector('.dashboard-body');
const render = (opts.renderers || {})[it.type];
if (body && typeof render === 'function') { try { render(body, it); } catch (e) { body.innerHTML = errCard(e); } }
if (o && o.resize) {
cell.classList.remove('is-sized');
const grid = host.querySelector('.dashboard-grid');
if (grid) armPersist(host, grid, layout, opts);
}
};
panel.innerHTML = '';
fn(panel, it, commit);
panel.removeAttribute('hidden');
cell.classList.add('is-settings-open');
teardownSettingsOutside();
settingsOutsideHandler = ev => {
if (panel.contains(ev.target)) return;
if (ev.target.closest('[data-act="settings"]')) return;
closeSettingsPanel(panel);
};
document.addEventListener('mousedown', settingsOutsideHandler, true);
return;
}
if (act === 'add') {
const c = catalog[Number(btn.dataset.idx)];
if (!c || !DASHBOARD.WIDGETS[c.type]) return;
const inst = newInstance(c); inst.x = 0; inst.y = layoutBottom(layout);
layout.push(inst);
await writeLayout(layout); await rerender(host); return;
}
if (act.startsWith('profile-')) { await profileAction(host, opts, act, btn); return; }
const i = Number(btn.dataset.i);
const it = layout[i];
if (!it) return;
if (act === 'remove') layout.splice(i, 1);
else return;
await writeLayout(layout);
await rerender(host);
};
}
async function profileAction(host, opts, act, btn) {
const huidig = await activeProfile();
try {
if (act === 'profile-pick') {
const id = Number(btn.dataset.id);
if (!Number.isInteger(id) || id === huidig.id) return;
await activateProfile(id);
} else if (act === 'profile-new') {
const res = await dialog.form({
title: t('dashboard.profileNew'),
fields: [{ key: 'name', label: t('dashboard.profileNewPrompt'), value: '' }],
});
if (!res || !String(res.name || '').trim()) return;
await activateProfile(await createProfile({ name: res.name }));
} else if (act === 'profile-rename') {
if (huidig.isSystem) return;
const res = await dialog.form({
title: t('dashboard.profileRename'),
fields: [{ key: 'name', label: t('dashboard.profileRenamePrompt'), value: huidig.name }],
});
if (!res || !String(res.name || '').trim()) return;
await renameProfile(huidig.id, { name: res.name });
} else if (act === 'profile-delete') {
const vraag = huidig.isSystem ? t('dashboard.profileRestartConfirm') : t('dashboard.profileDeleteConfirm');
if (!(await dialog.confirm({ message: vraag, danger: true }))) return;
await deleteProfile(huidig.id);
} else return;
} catch (err) {
dialog.alert({ title: t('dialog.error'), message: err.message });
}
host.innerHTML = '';
await rerender(host);
}
function editChangeHandler(host, layout, opts) {
return async e => {
const sel = e.target.closest('select[data-act="width"]');
if (!sel || !host.contains(sel)) return;
const it = layout[Number(sel.dataset.i)];
if (!it) return;
it.w = clampSpan(sel.value);
it.h = 0;
await writeLayout(layout);
await rerender(host);
};
}
const MOBILE_QUERY = '(max-width:768px)';
let addPop = null;
function addPopOutside(e) {
if (addPop && !addPop.contains(e.target) && !e.target.closest('.dash-add-bar')) closeAddPop();
}
function closeAddPop() {
if (!addPop) return;
addPop.remove();
addPop = null;
document.removeEventListener('mousedown', addPopOutside, true);
}
function setupAddBar(host, layout, catalog, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
const bar = document.createElement('div');
bar.className = 'dash-add-bar';
bar.hidden = true;
bar.innerHTML = `<span class="dash-add-bar-plus" role="button" title="${esc(t('dashboard.add'))}" aria-label="${esc(t('dashboard.add'))}">${PLUS}</span>`;
grid.appendChild(bar);
let run = null;
const cellAt = (clientX, clientY) => {
const gr = grid.getBoundingClientRect();
const cs = getComputedStyle(grid);
const colGap = parseFloat(cs.columnGap) || 0;
const rowGap = parseFloat(cs.rowGap) || 0;
const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS;
return {
col: Math.floor((clientX - gr.left) / (colW + colGap)),
row: Math.floor((clientY - gr.top) / (stepPx() + rowGap)),
};
};
const hide = () => { if (!addPop) { bar.hidden = true; run = null; } };
const follow = (target, clientX, clientY) => {
if (addPop || grid.classList.contains('is-dragging-active') || matchMedia(MOBILE_QUERY).matches) return;
if (target.closest('.dash-add-bar')) return;
if (target.closest('.dashboard-cell')) { hide(); return; }
const { col, row } = cellAt(clientX, clientY);
const r = freeRunAt(layout, col, row, spanSteps);
if (!r) { hide(); return; }
run = r;
bar.style.gridColumn = `${r.x + 1} / span ${r.w}`;
bar.style.gridRow = `${r.y + 1} / span 1`;
bar.hidden = false;
};
grid.addEventListener('pointermove', e => follow(e.target, e.clientX, e.clientY));
grid.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') follow(e.target, e.clientX, e.clientY); });
grid.addEventListener('pointerleave', hide);
bar.addEventListener('click', () => {
if (run) openAddPop(host, layout, catalog, opts, run, bar);
});
}
function openAddPop(host, layout, catalog, opts, run, bar) {
closeAddPop();
const items = fittingCatalog(catalog, run);
const pop = document.createElement('div');
pop.className = 'dashboard-add-menu dash-add-pop is-open';
pop.innerHTML = items.length
? items.map((c, i) => `<button type="button" class="dashboard-add-item" data-idx="${i}">${itemLabelHtml(c)}</button>`).join('')
: `<div class="dashboard-add-empty">${esc(catalog.length ? t('dashboard.addNoFit') : t('dashboard.addNone'))}</div>`;
document.body.appendChild(pop);
addPop = pop;
const r = bar.getBoundingClientRect();
const w = pop.offsetWidth, h = pop.offsetHeight;
const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2));
const top = r.bottom + 6 + h > window.innerHeight ? Math.max(8, r.top - 6 - h) : r.bottom + 6;
pop.style.left = left + 'px';
pop.style.top = top + 'px';
document.addEventListener('mousedown', addPopOutside, true);
pop.addEventListener('click', async e => {
const btn = e.target.closest('[data-idx]');
const c = btn && items[Number(btn.dataset.idx)];
if (!c) return;
closeAddPop();
const inst = newInstance(c);
Object.assign(inst, placeInRun(run, inst.w));
layout.push(inst);
makeRoom(layout, [layout.length - 1]);
await writeLayout(layout);
await rerender(host);
});
}
let swallowClick = false;
let swallowBound = false;
function setupLongPress(host, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
if (!swallowBound) {
swallowBound = true;
window.addEventListener('click', e => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
}, true);
window.addEventListener('pointerdown', () => { swallowClick = false; }, true);
}
grid.addEventListener('pointerdown', e => {
swallowClick = false;
if (e.pointerType !== 'mouse' || e.button !== 0) return;
if (!e.target.closest('.dashboard-cell')) return;
const x0 = e.clientX, y0 = e.clientY;
const stop = () => {
clearTimeout(timer);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
const onMove = ev => {
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) > DASHBOARD.LONG_PRESS_SLOP_PX) stop();
};
const timer = setTimeout(() => {
stop();
swallowClick = true;
opts.onEdit(true);
}, DASHBOARD.LONG_PRESS_MS);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
});
}
let editExitHandlers = null;
function teardownEditExit() {
if (!editExitHandlers) return;
document.removeEventListener('keydown', editExitHandlers.key);
document.removeEventListener('click', editExitHandlers.click);
editExitHandlers = null;
}
const EDIT_INSIDE = '.dashboard-cell, .dash-add-bar,.dashboard-add-menu, .dash-add-mobile, #page-tools, #nav-tools, #sidebar, .dialog-overlay';
function setupEditExit(host, opts) {
const done = () => {
teardownEditExit();
if (host.isConnected) opts.onEdit(false);
};
const key = e => {
if (e.key !== 'Escape') return;
if (!host.isConnected) { teardownEditExit(); return; }
if (e.defaultPrevented || !e.target.isConnected || document.querySelector('.dialog-overlay')) return;
const panel = host.querySelector('.dash-settings:not([hidden])');
if (panel) { closeSettingsPanel(panel); return; }
if (addPop) { closeAddPop(); return; }
done();
};
const click = e => {
if (!e.target.isConnected) return;
if (e.target.closest(EDIT_INSIDE)) return;
done();
};
editExitHandlers = { key, click };
document.addEventListener('keydown', key);
document.addEventListener('click', click);
}
function setupDrag(host, layout, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
grid.addEventListener('pointerdown', e => {
if (e.button != null && e.button !== 0) return;
const handle = e.target.closest('.dash-edit-grip');
if (!handle || !grid.contains(handle)) return;
const fromCell = handle.closest('.dashboard-cell');
if (!fromCell) return;
e.preventDefault();
startDrag({ host, grid, layout, opts, fromCell, fromIdx: Number(fromCell.dataset.cell), startX: e.clientX, startY: e.clientY });
});
}
function swallowNextClick() {
const eat = e => { e.preventDefault(); e.stopImmediatePropagation(); off(); };
const off = () => {
clearTimeout(timer);
window.removeEventListener('click', eat, true);
window.removeEventListener('pointerdown', off, true);
};
const timer = setTimeout(off, 300);
window.addEventListener('click', eat, true);
window.addEventListener('pointerdown', off, true);
}
function startDrag({ host, grid, layout, opts, fromCell, fromIdx, startX, startY }) {
const it = layout[fromIdx];
if (!it) return;
const w = clampSpan(it.w);
const h = spanSteps(it);
grid.classList.add('is-dragging-active');
fromCell.classList.add('is-dragging');
const srcRect = fromCell.getBoundingClientRect();
const grabX = startX - srcRect.left, grabY = startY - srcRect.top;
const srcInner = fromCell.querySelector('.dashboard-body > *');
const ghost = document.createElement('div');
ghost.className = 'dashboard-drag-ghost';
ghost.style.width = srcRect.width + 'px';
ghost.style.left = srcRect.left + 'px';
ghost.style.top = srcRect.top + 'px';
if (srcInner) ghost.appendChild(srcInner.cloneNode(true));
document.body.appendChild(ghost);
const moveGhost = (x, y) => { ghost.style.left = (x - grabX) + 'px'; ghost.style.top = (y - grabY) + 'px'; };
const preview = document.createElement('div');
preview.className = 'dashboard-drop-preview';
grid.appendChild(preview);
let target = null;
let swapCell = null;
const cellByIdx = idx => grid.querySelector('.dashboard-cell[data-cell="' + idx + '"]');
const clearSwap = () => { if (swapCell) { swapCell.classList.remove('is-drop-swap'); swapCell = null; } };
const snapCell = (clientX, clientY) => {
const gr = grid.getBoundingClientRect();
const cs = getComputedStyle(grid);
const colGap = parseFloat(cs.columnGap) || 0;
const rowGap = parseFloat(cs.rowGap) || 0;
const step = stepPx();
const colW = (gr.width - (DASHBOARD.GRID_COLUMNS - 1) * colGap) / DASHBOARD.GRID_COLUMNS;
let x = Math.round(((clientX - grabX) - gr.left) / (colW + colGap));
x = Math.max(0, Math.min(DASHBOARD.GRID_COLUMNS - w, x));
const y = Math.max(0, Math.round(((clientY - grabY) - gr.top) / (step + rowGap)));
return { x, y };
};
const computeTarget = (clientX, clientY) => {
let over = document.elementFromPoint(clientX, clientY);
over = over && over.closest ? over.closest('.dashboard-cell') : null;
if (over && grid.contains(over) && Number(over.dataset.cell) !== fromIdx) {
return { mode: 'swap', overIdx: Number(over.dataset.cell) };
}
const s = snapCell(clientX, clientY);
return { mode: 'insert', x: s.x, y: s.y };
};
const showTarget = tg => {
if (tg.mode === 'swap') {
preview.style.display = 'none';
const cell = cellByIdx(tg.overIdx);
if (swapCell && swapCell !== cell) clearSwap();
if (cell) { swapCell = cell; cell.classList.add('is-drop-swap'); }
} else {
clearSwap();
preview.style.display = '';
preview.style.gridColumn = (tg.x + 1) + ' / span ' + w;
preview.style.gridRow = (tg.y + 1) + ' / span ' + h;
}
};
const onMove = ev => { moveGhost(ev.clientX, ev.clientY); target = computeTarget(ev.clientX, ev.clientY); showTarget(target); };
moveGhost(startX, startY);
target = computeTarget(startX, startY);
showTarget(target);
const finish = async commit => {
if (commit) swallowNextClick();
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
clearSwap();
preview.remove();
ghost.remove();
grid.classList.remove('is-dragging-active');
fromCell.classList.remove('is-dragging');
if (!commit || !target) return;
if (target.mode === 'swap') {
const b = layout[target.overIdx];
if (!b || target.overIdx === fromIdx) return;
const ax = it.x, ay = it.y;
it.x = b.x; it.y = b.y;
b.x = ax; b.y = ay;
if (rectsOverlap(it, b)) makeRoom(layout, [fromIdx]);
else makeRoom(layout, [fromIdx, target.overIdx]);
} else {
if (target.x === it.x && target.y === it.y) return;
it.x = target.x; it.y = target.y;
makeRoom(layout, [fromIdx]);
}
await writeLayout(layout);
await rerender(host);
};
const onUp = () => finish(true);
const onCancel = () => finish(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
}
function packGrid(grid) {
if (!grid) return;
const gap = parseFloat(getComputedStyle(grid).columnGap) || 0;
const rows = Math.round(stepPx()) + 'px';
const rowGap = Math.round(gap) + 'px';
if (grid.style.gridAutoRows !== rows) grid.style.gridAutoRows = rows;
if (grid.style.rowGap !== rowGap) grid.style.rowGap = rowGap;
}
function scheduleMasonry(grid) {
if (!grid || grid.__packQueued) return;
grid.__packQueued = true;
requestAnimationFrame(() => { grid.__packQueued = false; packGrid(grid); });
}
function setupMasonry(host, layout, opts) {
const grid = host.querySelector('.dashboard-grid');
if (!grid) return;
packGrid(grid);
if (typeof ResizeObserver !== 'undefined') {
if (masonryRO) masonryRO.disconnect();
masonryRO = new ResizeObserver(() => { scheduleMasonry(grid); armPersist(host, grid, layout, opts); });
grid.querySelectorAll(':scope > .dashboard-cell').forEach(c => masonryRO.observe(c));
}
if (typeof MutationObserver !== 'undefined') {
if (masonryMO) masonryMO.disconnect();
if (growCheckTimer) { clearTimeout(growCheckTimer); growCheckTimer = null; }
masonryMO = new MutationObserver(() => scheduleGrowCheck(host, grid, layout, opts));
grid.querySelectorAll(':scope > .dashboard-cell > .dashboard-body')
.forEach(b => masonryMO.observe(b, { childList: true, subtree: true, characterData: true }));
}
if (!masonryResizeBound) {
masonryResizeBound = true;
window.addEventListener('resize', () => {
const g = document.querySelector('.dashboard-grid');
if (g) scheduleMasonry(g);
});
}
armPersist(host, grid, layout, opts);
}
function armPersist(host, grid, layout, opts) {
if (masonrySettleTimer) clearTimeout(masonrySettleTimer);
masonrySettleTimer = setTimeout(() => persistSettled(host, grid, layout, opts), DASHBOARD.SETTLE_MS);
}
function scheduleGrowCheck(host, grid, layout, opts) {
if (masonrySettleTimer || growCheckTimer) return;
growCheckTimer = setTimeout(() => { growCheckTimer = null; persistSettled(host, grid, layout, opts); }, DASHBOARD.SETTLE_MS);
}
async function persistSettled(host, grid, layout, opts) {
masonrySettleTimer = null;
if (!grid.isConnected) return;
if (!grid.getBoundingClientRect().height) {
if (grid.querySelector(':scope > .dashboard-cell')) armPersist(host, grid, layout, opts);
return;
}
const step = stepPx();
const gap = parseFloat(getComputedStyle(grid).columnGap) || 0;
const minSteps = DASHBOARD.MIN_H;
const GROW_TOL = 4;
const grown = [];
grid.querySelectorAll(':scope > .dashboard-cell').forEach(cell => {
const idx = Number(cell.dataset.cell);
const it = layout[idx];
if (!it) return;
const bodyEl = cell.querySelector('.dashboard-body');
const content = Math.round((bodyEl || cell).scrollHeight);
const wanted = Math.max(minSteps, stepsForPx(content, step, gap));
if (it.h <= 0) { if (wanted !== it.h) { it.h = wanted; grown.push(idx); } }
else if (content > pxForSteps(it.h, step, gap) + GROW_TOL && wanted > it.h) { it.h = wanted; grown.push(idx); }
});
if (!grown.length) { revealGrid(grid); return; }
const fresh = grid.__freshSettle; grid.__freshSettle = false;
if (fresh) packLayout(layout); else makeRoom(layout, grown);
await writeLayout(layout);
try { await setPref(LAYOUT_SCOPE, MODEL_KEY, DASHBOARD.HEIGHT_MODEL); } catch { }
await rerender(host);
}
function revealGrid(grid) {
if (masonryRevealTimer) { clearTimeout(masonryRevealTimer); masonryRevealTimer = null; }
if (!grid || !grid.classList.contains('is-loading')) return;
const host = grid.parentElement;
const skel = host && host.querySelector(':scope > .dashboard-skeleton');
if (skel) skel.remove();
grid.classList.remove('is-loading');
}
function skeletonHtml(layout) {
const step = stepPx();
const cells = layout.map(it => {
const steps = it.h > 0 ? it.h : DASHBOARD.SKELETON_STEPS;
const h = Math.round(steps * step);
return `<div class="dashboard-skel-cell" style="grid-column: span ${it.w}; min-height:${h}px">${loading.card()}</div>`;
}).join('');
return `<div class="dashboard-skeleton" aria-hidden="true"><div class="dashboard-skel-grid">${cells}</div></div>`;
}