diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 243a3f2..04a38ac 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -35,6 +35,7 @@ $routes->group('', ['filter' => 'auth'], static function ($routes) {
$routes->post('pin/(:num)', 'Dashboard\DashboardController::pinToggle/$1');
$routes->post('(:num)/layout', 'Dashboard\DashboardController::saveLayout/$1');
$routes->post('(:num)/widget', 'Dashboard\DashboardController::addWidget/$1');
+ $routes->post('widget/(:num)/update', 'Dashboard\DashboardController::updateWidget/$1');
$routes->post('widget/(:num)/delete', 'Dashboard\DashboardController::removeWidget/$1');
});
$routes->get('profile', 'ProfileController::index');
diff --git a/app/Controllers/Dashboard/DashboardController.php b/app/Controllers/Dashboard/DashboardController.php
index edd0364..21cfdf3 100644
--- a/app/Controllers/Dashboard/DashboardController.php
+++ b/app/Controllers/Dashboard/DashboardController.php
@@ -107,6 +107,7 @@ class DashboardController extends BaseController
'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout',
'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget',
'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',
+ 'updateWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',
'savedQueryVars' => rtrim(base_url(), '/') . '/chart/saved-query/',
],
'csrf' => [
@@ -324,6 +325,63 @@ class DashboardController extends BaseController
]);
}
+ // Keep DB row order aligned with visual layout (top-to-bottom, left-to-right) so PHP render matches the grid.
+ $posById = [];
+ foreach ($items as $row) {
+ if (! is_array($row)) {
+ continue;
+ }
+ $wid = (int) ($row['id'] ?? 0);
+ if ($wid <= 0) {
+ continue;
+ }
+ $posById[$wid] = [
+ 'y' => (int) ($row['y'] ?? 0),
+ 'x' => (int) ($row['x'] ?? 0),
+ ];
+ }
+ $allWidgets = $widgetModel->where('dashboard_id', $id)->findAll();
+ usort($allWidgets, static function (array $a, array $b) use ($posById): int {
+ $ida = (int) $a['id'];
+ $idb = (int) $b['id'];
+ $pa = $posById[$ida] ?? null;
+ $pb = $posById[$idb] ?? null;
+ if ($pa !== null && $pb !== null) {
+ if ($pa['y'] !== $pb['y']) {
+ return $pa['y'] <=> $pb['y'];
+ }
+ if ($pa['x'] !== $pb['x']) {
+ return $pa['x'] <=> $pb['x'];
+ }
+
+ return $ida <=> $idb;
+ }
+ if ($pa !== null) {
+ return -1;
+ }
+ if ($pb !== null) {
+ return 1;
+ }
+
+ $ya = (int) ($a['grid_y'] ?? 0);
+ $yb = (int) ($b['grid_y'] ?? 0);
+ if ($ya !== $yb) {
+ return $ya <=> $yb;
+ }
+ $xa = (int) ($a['grid_x'] ?? 0);
+ $xb = (int) ($b['grid_x'] ?? 0);
+ if ($xa !== $xb) {
+ return $xa <=> $xb;
+ }
+
+ return $ida <=> $idb;
+ });
+ $order = 1;
+ foreach ($allWidgets as $wRow) {
+ $widgetModel->update((int) $wRow['id'], ['sort_order' => $order]);
+ $order++;
+ }
+
$db->transComplete();
if (! $db->transStatus()) {
@@ -433,6 +491,49 @@ class DashboardController extends BaseController
]);
}
+ /**
+ * Update markdown content for a text widget (AJAX).
+ */
+ public function updateWidget(int $widgetId)
+ {
+ $workspaceId = (int) $this->session->get('active_workspace_id');
+ $widgetModel = new DashboardWidgetModel();
+ $w = $widgetModel->find($widgetId);
+
+ if (! $w) {
+ return $this->response->setJSON(['success' => false, 'message' => 'Widget not found.'])->setStatusCode(404);
+ }
+
+ $dashboard = (new DashboardModel())->findForWorkspace((int) $w['dashboard_id'], $workspaceId);
+ if (! $dashboard) {
+ return $this->response->setJSON(['success' => false, 'message' => 'Not found.'])->setStatusCode(404);
+ }
+
+ if (($w['widget_type'] ?? '') !== 'text') {
+ return $this->response->setJSON(['success' => false, 'message' => 'Only text widgets can be updated here.'])->setStatusCode(422);
+ }
+
+ $content = trim((string) $this->request->getPost('content'));
+ if ($content === '') {
+ return $this->response->setJSON(['success' => false, 'message' => 'Content is required.'])->setStatusCode(422);
+ }
+
+ $widgetModel->update($widgetId, [
+ 'content' => $content,
+ ]);
+
+ $fresh = $widgetModel->find($widgetId);
+
+ return $this->response->setJSON([
+ 'success' => true,
+ 'widget' => [
+ 'id' => (int) ($fresh['id'] ?? $widgetId),
+ 'content' => (string) ($fresh['content'] ?? ''),
+ ],
+ 'csrf' => ['name' => csrf_token(), 'hash' => csrf_hash()],
+ ]);
+ }
+
public function removeWidget(int $widgetId)
{
$workspaceId = (int) $this->session->get('active_workspace_id');
diff --git a/app/Libraries/ChartRenderer.php b/app/Libraries/ChartRenderer.php
index 869c292..c540ef8 100644
--- a/app/Libraries/ChartRenderer.php
+++ b/app/Libraries/ChartRenderer.php
@@ -17,29 +17,88 @@ class ChartRenderer
// 'violet' => ['#8b5cf6', '#a78bfa', '#c4b5fd', '#d946ef', '#7c3aed'],
// ];
+ // private const PALETTES = [
+
+ // 'mono' => ['#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc','#ffffff'],
+
+ // 'ocean' => ['#075985','#0369a1','#0284c7','#0ea5e9','#38bdf8','#7dd3fc','#bae6fd','#e0f2fe','#f0f9ff','#e6f6ff','#cceeff','#99ddff'],
+
+ // 'forest' => ['#052e16','#14532d','#166534','#15803d','#16a34a','#22c55e','#4ade80','#86efac','#bbf7d0','#dcfce7','#ecfdf5','#f0fdf4'],
+
+ // 'sunset' => ['#7c2d12','#9a3412','#c2410c','#ea580c','#f97316','#fb923c','#fdba74','#fed7aa','#ffedd5','#fff7ed','#fff1e6','#ffe4d6'],
+
+ // 'slate' => ['#020617','#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc'],
+
+ // 'violet' => ['#2e1065','#4c1d95','#5b21b6','#6d28d9','#7c3aed','#8b5cf6','#a78bfa','#c4b5fd','#ddd6fe','#ede9fe','#f5f3ff','#faf5ff'],
+
+ // 'rose' => ['#881337','#9f1239','#be123c','#e11d48','#f43f5e','#fb7185','#fda4af','#fecdd3','#ffe4e6','#fff1f2','#fff5f5','#ffeaea'],
+
+ // 'teal' => ['#042f2e','#134e4a','#0f766e','#0d9488','#14b8a6','#2dd4bf','#5eead4','#99f6e4','#ccfbf1','#f0fdfa','#e6fffa','#ccfffa'],
+
+ // 'amber' => ['#78350f','#92400e','#b45309','#d97706','#f59e0b','#fbbf24','#fcd34d','#fde68a','#fef3c7','#fffbeb','#fff7cc','#fff2b3'],
+
+ // 'indigo' => ['#1e1b4b','#312e81','#3730a3','#4338ca','#4f46e5','#6366f1','#818cf8','#a5b4fc','#c7d2fe','#e0e7ff','#eef2ff','#f5f7ff'],
+
+ // 'cool_gray' => ['#030712','#111827','#1f2937','#374151','#4b5563','#6b7280','#9ca3af','#d1d5db','#e5e7eb','#f3f4f6','#f9fafb','#ffffff'],
+
+ // ];
+
private const PALETTES = [
- 'mono' => ['#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc','#ffffff'],
+ 'mono' => [
+ '#6b7280','#9ca3af','#4b5563','#94a3b8','#64748b','#71717a',
+ '#a1a1aa','#737373','#9ca3af','#6b7280','#4b5563','#71717a'
+ ],
- 'ocean' => ['#075985','#0369a1','#0284c7','#0ea5e9','#38bdf8','#7dd3fc','#bae6fd','#e0f2fe','#f0f9ff','#e6f6ff','#cceeff','#99ddff'],
+ 'ocean' => [
+ '#0284c7','#0ea5e9','#38bdf8','#7dd3fc','#06b6d4','#22d3ee',
+ '#67e8f9','#0891b2','#0ea5e9','#38bdf8','#06b6d4','#22d3ee'
+ ],
- 'forest' => ['#052e16','#14532d','#166534','#15803d','#16a34a','#22c55e','#4ade80','#86efac','#bbf7d0','#dcfce7','#ecfdf5','#f0fdf4'],
+ 'forest' => [
+ '#166534','#15803d','#16a34a','#22c55e','#4ade80','#86efac',
+ '#65a30d','#84cc16','#4d7c0f','#22c55e','#16a34a','#15803d'
+ ],
- 'sunset' => ['#7c2d12','#9a3412','#c2410c','#ea580c','#f97316','#fb923c','#fdba74','#fed7aa','#ffedd5','#fff7ed','#fff1e6','#ffe4d6'],
+ 'sunset' => [
+ '#c2410c','#ea580c','#f97316','#fb923c','#fdba74','#f59e0b',
+ '#fbbf24','#facc15','#f97316','#fb923c','#ea580c','#f59e0b'
+ ],
- 'slate' => ['#020617','#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc'],
+ 'slate' => [
+ '#334155','#475569','#64748b','#94a3b8','#cbd5e1','#94a3b8',
+ '#64748b','#475569','#334155','#64748b','#94a3b8','#475569'
+ ],
- 'violet' => ['#2e1065','#4c1d95','#5b21b6','#6d28d9','#7c3aed','#8b5cf6','#a78bfa','#c4b5fd','#ddd6fe','#ede9fe','#f5f3ff','#faf5ff'],
+ 'violet' => [
+ '#6d28d9','#7c3aed','#8b5cf6','#a78bfa','#c4b5fd','#9333ea',
+ '#a855f7','#c084fc','#8b5cf6','#7c3aed','#9333ea','#a855f7'
+ ],
- 'rose' => ['#881337','#9f1239','#be123c','#e11d48','#f43f5e','#fb7185','#fda4af','#fecdd3','#ffe4e6','#fff1f2','#fff5f5','#ffeaea'],
+ 'rose' => [
+ '#be123c','#e11d48','#f43f5e','#fb7185','#fda4af','#f472b6',
+ '#ec4899','#f43f5e','#fb7185','#e11d48','#f472b6','#ec4899'
+ ],
- 'teal' => ['#042f2e','#134e4a','#0f766e','#0d9488','#14b8a6','#2dd4bf','#5eead4','#99f6e4','#ccfbf1','#f0fdfa','#e6fffa','#ccfffa'],
+ 'teal' => [
+ '#0f766e','#0d9488','#14b8a6','#2dd4bf','#5eead4','#2dd4bf',
+ '#14b8a6','#0d9488','#0f766e','#2dd4bf','#14b8a6','#0d9488'
+ ],
- 'amber' => ['#78350f','#92400e','#b45309','#d97706','#f59e0b','#fbbf24','#fcd34d','#fde68a','#fef3c7','#fffbeb','#fff7cc','#fff2b3'],
+ 'amber' => [
+ '#b45309','#d97706','#f59e0b','#fbbf24','#fcd34d','#f59e0b',
+ '#fbbf24','#f59e0b','#d97706','#b45309','#f59e0b','#fbbf24'
+ ],
- 'indigo' => ['#1e1b4b','#312e81','#3730a3','#4338ca','#4f46e5','#6366f1','#818cf8','#a5b4fc','#c7d2fe','#e0e7ff','#eef2ff','#f5f7ff'],
+ 'indigo' => [
+ '#3730a3','#4338ca','#4f46e5','#6366f1','#818cf8','#6366f1',
+ '#4f46e5','#4338ca','#3730a3','#6366f1','#4f46e5','#818cf8'
+ ],
- 'cool_gray' => ['#030712','#111827','#1f2937','#374151','#4b5563','#6b7280','#9ca3af','#d1d5db','#e5e7eb','#f3f4f6','#f9fafb','#ffffff'],
+ 'cool_gray' => [
+ '#374151','#4b5563','#6b7280','#9ca3af','#d1d5db','#9ca3af',
+ '#6b7280','#4b5563','#374151','#9ca3af','#6b7280','#4b5563'
+ ],
];
diff --git a/app/Models/DashboardWidgetModel.php b/app/Models/DashboardWidgetModel.php
index 78357dc..f544224 100644
--- a/app/Models/DashboardWidgetModel.php
+++ b/app/Models/DashboardWidgetModel.php
@@ -52,6 +52,8 @@ class DashboardWidgetModel extends Model
->select('charts.name AS chart_name, charts.chart_type, charts.saved_query_id')
->join('charts', 'charts.id = dashboard_widgets.chart_id', 'left')
->where('dashboard_widgets.dashboard_id', $dashboardId)
+ ->orderBy('dashboard_widgets.grid_y', 'ASC')
+ ->orderBy('dashboard_widgets.grid_x', 'ASC')
->orderBy('dashboard_widgets.sort_order', 'ASC')
->orderBy('dashboard_widgets.id', 'ASC')
->findAll();
diff --git a/app/Views/chart/builder.php b/app/Views/chart/builder.php
index 050e7be..332beeb 100644
--- a/app/Views/chart/builder.php
+++ b/app/Views/chart/builder.php
@@ -326,6 +326,9 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
Live preview
+
@@ -407,5 +410,32 @@ document.getElementById('cbExportPng')?.addEventListener('click', async function
alert('Could not export PNG.');
}
});
+
+(function () {
+ const btn = document.getElementById('cbPreviewFsBtn');
+ const icon = document.getElementById('cbPreviewFsIcon');
+ const card = document.querySelector('.cb-cb-preview-card');
+ if (!btn || !icon || !card) return;
+
+ const sync = () => {
+ const open = card.classList.contains('cb-cb-preview-fullscreen');
+ icon.textContent = open ? 'close_fullscreen' : 'open_in_full';
+ btn.title = open ? 'Exit fullscreen' : 'Fullscreen preview';
+ btn.setAttribute('aria-label', btn.title);
+ document.body.classList.toggle('cb-cb-preview-fs-open', open);
+ };
+
+ btn.addEventListener('click', () => {
+ card.classList.toggle('cb-cb-preview-fullscreen');
+ sync();
+ });
+
+ document.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape' && card.classList.contains('cb-cb-preview-fullscreen')) {
+ card.classList.remove('cb-cb-preview-fullscreen');
+ sync();
+ }
+ });
+})();
= $this->endSection() ?>
diff --git a/app/Views/dashboard/_view_scripts.php b/app/Views/dashboard/_view_scripts.php
index d30310e..08bd231 100644
--- a/app/Views/dashboard/_view_scripts.php
+++ b/app/Views/dashboard/_view_scripts.php
@@ -521,11 +521,14 @@
w.widget_type === 'image' ? 'Image' :
w.widget_type === 'filter_date' ? 'Date filter' : 'Filter'
);
- const editLink = (!READ_ONLY && w.widget_type === 'chart' && w.chart_id && boot.urls && boot.urls.chartEdit)
+ const chartEditLink = (!READ_ONLY && w.widget_type === 'chart' && w.chart_id && boot.urls && boot.urls.chartEdit)
? ('
edit')
: '';
+ const textEditBtn = (!READ_ONLY && w.widget_type === 'text' && boot.urls && boot.urls.updateWidget)
+ ? ('
')
+ : '';
const rm = READ_ONLY ? '' : ('
');
- const actions = editLink + rm;
+ const actions = chartEditLink + textEditBtn + rm;
if (w.widget_type === 'chart') {
const exportPng = '
';
@@ -535,7 +538,7 @@
const exportExcel = canExcel
? '
table_view'
: '';
- const chartActions = exportPng + exportPdf + exportExcel + actions;
+ const chartActions = exportPng + exportPdf + exportExcel + chartEditLink + rm;
const vars = await loadVariableDefs(w.saved_query_id);
let varsHtml = '';
if (vars.length) {
@@ -581,10 +584,13 @@
}
if (w.widget_type === 'text') {
+ const overlayHtml = (!READ_ONLY)
+ ? ('
' + actions + '
')
+ : '';
mount.innerHTML =
- '
';
+ '
';
renderMarkdown(document.getElementById('cb-md-' + w.id), w.content || '');
return;
}
@@ -671,11 +677,163 @@
});
}
+ function getRootGridStackItems() {
+ if (grid && typeof grid.getGridItems === 'function') return grid.getGridItems();
+ const root = document.getElementById('cbGridStack');
+ if (!root) return [];
+ return Array.from(root.children).filter((el) => el.classList && el.classList.contains('grid-stack-item'));
+ }
+
+ function gridNum(v, fallback) {
+ const n = Number(v);
+ return Number.isFinite(n) ? n : fallback;
+ }
+
+ /**
+ * Re-apply server grid_* using grid.load() so layout is applied atomically (avoids per-item
+ * update collisions). Always set sizeToContent:false so GridStack does not resize rows from
+ * DOM content (this was moving/growing text widgets ~300ms after load via resizeToContentCheck).
+ */
+ function applyLayoutFromBoot(force) {
+ if (READ_ONLY || !grid || !boot.widgets || !boot.widgets.length) return;
+ if (!force && layoutDirty) return;
+ if (typeof grid._cbSetLayoutSync === 'function') grid._cbSetLayoutSync(true);
+ const prevAnim = grid.opts && grid.opts.animate;
+ try {
+ if (grid.opts) grid.opts.animate = false;
+ const items = boot.widgets.map((w) => {
+ const x = gridNum(w.grid_x, 0);
+ const y = gridNum(w.grid_y, 0);
+ const gw = gridNum(w.grid_w, 4);
+ const gh = gridNum(w.grid_h, 3);
+ return {
+ id: String(w.id),
+ x: Math.max(0, Math.min(11, x)),
+ y: Math.max(0, y),
+ w: Math.max(1, Math.min(12, gw)),
+ h: Math.max(1, Math.min(24, gh)),
+ sizeToContent: false,
+ };
+ });
+ /* false = do not remove DOM nodes missing from the list; only move/resize existing */
+ grid.load(items, false);
+ grid.getGridItems().forEach((el) => {
+ grid.update(el, { sizeToContent: false });
+ });
+ } finally {
+ if (grid.opts && prevAnim !== undefined) grid.opts.animate = prevAnim;
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (typeof grid._cbSetLayoutSync === 'function') grid._cbSetLayoutSync(false);
+ });
+ });
+ }
+ }
+
+ function readLayoutFromElement(el) {
+ const id = parseInt(String(el.getAttribute('gs-id') || ''), 10);
+ if (!Number.isFinite(id) || id <= 0) return null;
+
+ // Prefer DOM attrs first: GridStack keeps these as the canonical serialized
+ // values for save/load, and they are updated after drag/resize settles.
+ const hasX = el.hasAttribute('gs-x');
+ const hasY = el.hasAttribute('gs-y');
+ const hasW = el.hasAttribute('gs-w');
+ const hasH = el.hasAttribute('gs-h');
+ if (hasX || hasY || hasW || hasH) {
+ const x = gridNum(el.getAttribute('gs-x'), 0);
+ const y = gridNum(el.getAttribute('gs-y'), 0);
+ const w = gridNum(el.getAttribute('gs-w'), 1);
+ const h = gridNum(el.getAttribute('gs-h'), 1);
+ return {
+ id,
+ x: Math.max(0, Math.min(11, x)),
+ y: Math.max(0, y),
+ w: Math.max(1, Math.min(12, w)),
+ h: Math.max(1, Math.min(24, h)),
+ };
+ }
+
+ // Fallback for edge cases where attrs are not yet present.
+ const gn = el.gridstackNode;
+ if (gn && Number.isFinite(gn.x) && Number.isFinite(gn.y) && Number.isFinite(gn.w) && Number.isFinite(gn.h)) {
+ return { id, x: gn.x, y: gn.y, w: gn.w, h: gn.h };
+ }
+ return { id, x: 0, y: 0, w: 1, h: 1 };
+ }
+
+ function collectLayoutItemsForSave() {
+ if (!grid) return [];
+ const byId = new Map();
+ // Prefer live DOM nodes so every gs-id tile is saved (some engines omit nodes from save()).
+ getRootGridStackItems().forEach((el) => {
+ const row = readLayoutFromElement(el);
+ if (row) byId.set(row.id, row);
+ });
+ grid.save(false).forEach((n) => {
+ const id = parseInt(String(n.id == null ? '' : n.id), 10);
+ if (Number.isFinite(id) && id > 0) {
+ const prev = byId.get(id) || { id, x: 0, y: 0, w: 1, h: 1 };
+ const nx = Number.isFinite(n.x) ? n.x : prev.x;
+ const ny = Number.isFinite(n.y) ? n.y : prev.y;
+ const nw = Number.isFinite(n.w) ? n.w : prev.w;
+ const nh = Number.isFinite(n.h) ? n.h : prev.h;
+ byId.set(id, {
+ id,
+ x: Math.max(0, Math.min(11, nx)),
+ y: Math.max(0, ny),
+ w: Math.max(1, Math.min(12, nw)),
+ h: Math.max(1, Math.min(24, nh)),
+ });
+ }
+ });
+ return Array.from(byId.values());
+ }
+
+ /**
+ * Text widgets are the only tiles without a header block and can appear to "drift"
+ * after async chart/layout paint settles. Re-apply their persisted grid box when
+ * dashboard is not being edited so reload state stays identical to saved state.
+ */
+ function enforceTextWidgetLayoutFromBoot(force) {
+ if (READ_ONLY || !grid || !boot.widgets || !boot.widgets.length) return;
+ if (!force && (editing || layoutDirty)) return;
+ if (typeof grid._cbSetLayoutSync === 'function') grid._cbSetLayoutSync(true);
+ try {
+ const textWidgets = boot.widgets.filter((w) => w.widget_type === 'text');
+ if (!textWidgets.length) return;
+ grid.batchUpdate(true);
+ textWidgets.forEach((w) => {
+ const el = getRootGridStackItems().find((node) => parseInt(node.getAttribute('gs-id') || '0', 10) === w.id);
+ if (!el) return;
+ const x = gridNum(w.grid_x, 0);
+ const y = gridNum(w.grid_y, 0);
+ const gw = gridNum(w.grid_w, 4);
+ const gh = gridNum(w.grid_h, 3);
+ grid.update(el, {
+ x: Math.max(0, Math.min(11, x)),
+ y: Math.max(0, y),
+ w: Math.max(1, Math.min(12, gw)),
+ h: Math.max(1, Math.min(24, gh)),
+ sizeToContent: false,
+ });
+ });
+ } finally {
+ grid.batchUpdate(false);
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (typeof grid._cbSetLayoutSync === 'function') grid._cbSetLayoutSync(false);
+ });
+ });
+ }
+ }
+
async function initMounts() {
for (const w of boot.widgets) {
await mountWidget(w);
}
bindRemoveDelegation();
+ layoutDirty = false;
setEditUi();
}
@@ -685,12 +843,26 @@
document.getElementById('cbBtnDiscard')?.classList.toggle('d-none', !editing || !layoutDirty);
const addW = document.getElementById('cbBtnAddWidget');
if (addW) addW.hidden = !editing;
- const tl = document.getElementById('cbBtnToggleEditLabel');
- if (tl) tl.textContent = editing ? 'Done editing' : 'Edit layout';
+ const iconEdit = document.querySelector('.cb-dash-toggle-icon--edit');
+ const iconClose = document.querySelector('.cb-dash-toggle-icon--close');
+ const btnToggle = document.getElementById('cbBtnToggleEdit');
+ if (iconEdit && iconClose) {
+ iconEdit.classList.toggle('d-none', editing);
+ iconClose.classList.toggle('d-none', !editing);
+ }
+ if (btnToggle) {
+ const done = editing ? 'Done editing' : 'Edit layout';
+ btnToggle.setAttribute('aria-label', done);
+ btnToggle.title = done;
+ }
const gs = document.getElementById('cbGridStack');
if (gs) gs.classList.toggle('cb-dash-editing', editing);
document.querySelectorAll('.cb-dash-widget-remove').forEach((b) => b.classList.toggle('d-none', !editing));
document.querySelectorAll('.cb-dash-widget-edit').forEach((b) => b.classList.toggle('d-none', !editing));
+ document.querySelectorAll('.cb-dash-widget-text-actions--overlay').forEach((box) => {
+ const any = box.querySelector('.cb-dash-widget-edit:not(.d-none), .cb-dash-widget-remove:not(.d-none)');
+ box.classList.toggle('d-none', !any);
+ });
}
function initGrid() {
@@ -706,11 +878,20 @@
marginLeft: 8,
marginRight: 8,
animate: true,
+ /* false = keep saved x/y; true would vertically "float" items and can reorder after load */
float: false,
staticGrid: true,
+ disableOneColumnMode: true,
+ sizeToContent: false,
}, el);
- grid.on('change', () => { layoutDirty = true; setEditUi(); });
+ let _cbLayoutSync = false;
+ grid.on('change', () => {
+ if (_cbLayoutSync) return;
+ layoutDirty = true;
+ setEditUi();
+ });
+ grid._cbSetLayoutSync = (v) => { _cbLayoutSync = !!v; };
const setDraggingState = (on) => el.classList.toggle('cb-dash-dragging', !!on);
grid.on('dragstart', () => setDraggingState(true));
grid.on('dragstop', () => setDraggingState(false));
@@ -726,19 +907,67 @@
document.getElementById('cbBtnToggleEdit')?.addEventListener('click', () => { if (!READ_ONLY) toggleEdit(); });
- document.getElementById('cbBtnExportDashPdf')?.addEventListener('click', () => { exportDashboardPdf(); });
+ document.getElementById('cbGridStack')?.addEventListener('click', (ev) => {
+ const btn = ev.target.closest('[data-edit-text]');
+ if (!btn || READ_ONLY) return;
+ const wid = parseInt(btn.getAttribute('data-edit-text'), 10);
+ if (!wid) return;
+ const w = boot.widgets.find((x) => x.id === wid);
+ if (!w || w.widget_type !== 'text') return;
+ const hid = document.getElementById('cbEditTextWidgetId');
+ const ta = document.getElementById('cbEditTextMarkdown');
+ if (hid) hid.value = String(wid);
+ if (ta) ta.value = w.content || '';
+ if (window.bootstrap && document.getElementById('modalEditTextWidget')) {
+ bootstrap.Modal.getOrCreateInstance(document.getElementById('modalEditTextWidget')).show();
+ }
+ });
+
+ document.getElementById('cbBtnSaveTextWidget')?.addEventListener('click', async () => {
+ if (READ_ONLY || !boot.urls || !boot.urls.updateWidget) return;
+ const hid = document.getElementById('cbEditTextWidgetId');
+ const ta = document.getElementById('cbEditTextMarkdown');
+ const wid = parseInt(hid?.value || '0', 10);
+ if (!wid) return;
+ const content = (ta?.value || '').trim();
+ if (!content) { alert('Content is required.'); return; }
+ const body = new URLSearchParams();
+ if (CB_CSRF && CB_CSRF.name) body.append(CB_CSRF.name, CB_CSRF.hash);
+ body.append('content', ta.value);
+ const r = await fetch(boot.urls.updateWidget + wid + '/update', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ const j = await r.json();
+ patchCsrf(j);
+ if (!j.success) { alert(j.message || 'Could not save'); return; }
+ const upd = j.widget;
+ if (upd && upd.id) {
+ const ix = boot.widgets.findIndex((x) => x.id === upd.id);
+ if (ix >= 0) boot.widgets[ix].content = upd.content;
+ }
+ const mdEl = document.getElementById('cb-md-' + wid);
+ renderMarkdown(mdEl, upd && upd.content !== undefined ? upd.content : '');
+ if (window.bootstrap && document.getElementById('modalEditTextWidget')) {
+ bootstrap.Modal.getInstance(document.getElementById('modalEditTextWidget'))?.hide();
+ }
+ });
+
+ document.getElementById('cbBtnExportDashPdf')?.addEventListener('click', () => {
+ const pdfMenuToggle = document.getElementById('cbDashPdfMenuBtn');
+ if (pdfMenuToggle && typeof bootstrap !== 'undefined') {
+ const inst = bootstrap.Dropdown.getInstance(pdfMenuToggle);
+ if (inst) inst.hide();
+ }
+ exportDashboardPdf();
+ });
document.getElementById('cbBtnSaveLayout')?.addEventListener('click', async () => {
if (READ_ONLY || !boot.urls || !boot.urls.saveLayout) return;
if (!grid) return;
- const nodes = grid.save(false);
- const items = nodes.map((n) => ({
- id: parseInt(n.id, 10),
- x: n.x,
- y: n.y,
- w: n.w,
- h: n.h,
- })).filter((x) => x.id > 0);
+ const items = collectLayoutItemsForSave();
const body = new URLSearchParams();
if (CB_CSRF && CB_CSRF.name) body.append(CB_CSRF.name, CB_CSRF.hash);
@@ -857,6 +1086,7 @@
const content = '
';
grid.addWidget({ id: String(nw.id), x: nw.grid_x, y: nw.grid_y, w: nw.grid_w, h: nw.grid_h, content });
await mountWidget(nw);
+ applyLayoutFromBoot(true);
}
layoutDirty = true;
setEditUi();
@@ -866,8 +1096,27 @@
}
});
+ /**
+ * Apply DB grid_* after widgets are mounted. Running this before mount meant empty chart
+ * shells and float reflow could move text widgets (especially) to the wrong row/size after load.
+ */
+ function finalizeLayoutFromServer() {
+ layoutDirty = false;
+ applyLayoutFromBoot(true);
+ enforceTextWidgetLayoutFromBoot(true);
+ layoutDirty = false;
+ setEditUi();
+ }
+
initGrid();
+ layoutDirty = false;
initMounts().then(() => {
+ finalizeLayoutFromServer();
+ /* GridStack may run resizeToContentCheck on a ~300ms timer after first layout; re-lock layout once after that. */
+ setTimeout(() => finalizeLayoutFromServer(), 360);
+ /* Extra stabilization after async chart mounts/paint for text widgets only. */
+ setTimeout(() => enforceTextWidgetLayoutFromBoot(false), 900);
+ setTimeout(() => enforceTextWidgetLayoutFromBoot(false), 1800);
const sec = Number(boot.refreshSec || 0);
if (sec > 0) {
refreshTimer = setInterval(() => refreshAllCharts(), sec * 1000);
diff --git a/app/Views/dashboard/view.php b/app/Views/dashboard/view.php
index fd30817..fa4c31e 100644
--- a/app/Views/dashboard/view.php
+++ b/app/Views/dashboard/view.php
@@ -22,33 +22,54 @@ $themeClass = match ($theme) {
= esc($dashboard['description'] ?? '') ?>
+
+
= $this->endSection() ?>
diff --git a/app/Views/share/dashboard_view.php b/app/Views/share/dashboard_view.php
index 8c37cd8..9c21ba6 100644
--- a/app/Views/share/dashboard_view.php
+++ b/app/Views/share/dashboard_view.php
@@ -20,18 +20,30 @@ $themeClass = match ($theme) {
= esc($dashboard['name']) ?>
= esc($dashboard['description'] ?? '') ?>
-
-