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(); + } + }); +})(); 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 = - '
' + - '
' + escapeHtml(title) + '
' + actions + '
' + - '
'; + '
' + + overlayHtml + + '
'; 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) {
-
- - - - + +
-
- - -
- - + - - Settings - All boards + + + + + + +
@@ -151,6 +172,26 @@ $themeClass = match ($theme) { + + 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) {

-
- -
- - + -
diff --git a/public/assets/css/chart-builder.css b/public/assets/css/chart-builder.css index 2411ac9..ef876a1 100644 --- a/public/assets/css/chart-builder.css +++ b/public/assets/css/chart-builder.css @@ -169,6 +169,26 @@ flex-direction: column; } +body.cb-cb-preview-fs-open { + overflow: hidden; +} + +.cb-cb-preview-card.cb-cb-preview-fullscreen { + position: fixed !important; + inset: 12px; + z-index: 1300; + top: 12px !important; + width: auto !important; + height: auto !important; + max-height: none !important; + background: #fff; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25); +} + +.cb-cb-preview-card.cb-cb-preview-fullscreen .cb-cb-preview-host { + min-height: calc(100vh - 150px); +} + .cb-cb-config-card { position: sticky; top: calc(var(--topbar-height) + 14px); diff --git a/public/assets/css/dashboard.css b/public/assets/css/dashboard.css index ad60932..e14ee65 100644 --- a/public/assets/css/dashboard.css +++ b/public/assets/css/dashboard.css @@ -1,4 +1,5 @@ /* Dashboard module — list + builder shell */ + .cb-dash-btn-primary { background: #fff; color: #0f172a; @@ -154,11 +155,15 @@ /* —— Builder canvas —— */ .cb-dash-shell { - /* Bleed horizontally into page gutters only; keep top margin ≥0 so toolbar clears the fixed topbar. */ + /* + * Do not use negative horizontal margins here: they widen the box past the main column and + * force overflow-x on an ancestor. Pairing overflow-x with visible overflow-y makes that + * ancestor gain a second vertical scrollbar (CSS overflow rules). + */ margin-top: 0; margin-bottom: 0; - margin-left: calc(-1 * var(--page-padding-x)); - margin-right: calc(-1 * var(--page-padding-x)); + margin-left: 0; + margin-right: 0; padding: calc(var(--page-padding-top) + 10px) var(--page-padding-x) 32px; min-height: calc(100vh - var(--topbar-height)); } @@ -250,6 +255,65 @@ padding-inline: 11px; } +/* Compact icon-only controls in dashboard toolbar */ +.cb-dash-toolbar .btn.cb-dash-icon-btn { + padding-inline: 0; + min-width: 34px; + justify-content: center; + gap: 0; +} + +.cb-dash-toolbar .btn.cb-dash-icon-btn .cb-btn-icon { + margin: 0; +} + +.cb-dash-toolbar .dropdown-toggle-hide-caret::after { + display: none; +} + +.cb-dash-toolbar .cb-dash-fs-btn .cb-btn-icon { + font-size: 19px; + font-variation-settings: 'FILL' 0, 'wght' 500, 'GRAD' 0, 'opsz' 24; +} + +.cb-dash-pdf-dd { + min-width: 220px; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-pdf-dd { + background: rgba(30, 41, 59, 0.98); + border: 1px solid rgba(148, 163, 184, 0.2) !important; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-pdf-dd .dropdown-item { + color: #e2e8f0; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-pdf-dd .dropdown-item:hover, +.cb-dash-shell.cb-theme-dark .cb-dash-pdf-dd .dropdown-item:focus { + background: rgba(148, 163, 184, 0.12); + color: #f8fafc; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-pdf-dd .dropdown-divider { + border-color: rgba(148, 163, 184, 0.2); +} + +.cb-dash-shell.cb-theme-dark .cb-dash-pdf-dd .form-check-label { + color: #cbd5e1; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-toolbar a.btn.btn-light.border.cb-dash-icon-btn { + background: rgba(30, 41, 59, 0.65); + border-color: rgba(148, 163, 184, 0.28) !important; + color: #e2e8f0; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-toolbar a.btn.btn-light.border.cb-dash-icon-btn:hover { + background: rgba(51, 65, 85, 0.9); + color: #f8fafc; +} + .cb-dash-toolbar .btn.btn-primary { box-shadow: 0 4px 12px rgba(37, 99, 235, 0.24); } @@ -267,18 +331,30 @@ border-right-color: rgba(148, 163, 184, 0.25); } +/* No overflow-x/y hidden here: overflow-x:hidden makes overflow-y compute to auto → scrollbar on THIS div. */ .cb-dash-grid-wrap { min-height: 400px; border-radius: 14px; transition: background 0.18s ease, box-shadow 0.18s ease; + width: 100%; + max-width: 100%; + overflow: visible; } -/* Stretch widgets to the full GridStack cell so paired cards align visually */ -.cb-dash-grid-wrap .grid-stack-item > .grid-stack-item-content { +.cb-dash-grid-wrap .grid-stack { + width: 100%; + min-width: 0; +} + +/* + * GridStack’s CSS sets overflow-y: auto on every .grid-stack-item-content (second scrollbar). + * #cbGridStack bumps specificity so this always wins after gridstack.min.css. + */ +#cbGridStack.grid-stack > .grid-stack-item > .grid-stack-item-content { height: 100%; display: flex; flex-direction: column; - overflow: hidden; + overflow: hidden !important; } .cb-dash-grid-wrap .cb-dash-widget-mount { @@ -401,7 +477,64 @@ border: 1px solid #edf1f6; border-radius: 10px; height: 100%; - overflow: auto; + overflow: hidden; +} + +/* Text widget: no header row — actions float over the card so short heights still show content */ +.cb-dash-widget-card--text { + position: relative; + overflow: hidden; +} + +.cb-dash-widget-text-actions--overlay { + position: absolute; + top: 4px; + right: 6px; + z-index: 4; + display: flex; + align-items: center; + gap: 2px; + padding: 2px 4px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 1px 6px rgba(15, 23, 42, 0.08); + pointer-events: auto; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-widget-text-actions--overlay { + background: rgba(30, 41, 59, 0.94); + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.35); +} + +.cb-dash-widget-body--text { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 14px 12px; +} + +/* While editing layout, keep space so floating icons don’t cover the first characters */ +.grid-stack.cb-dash-editing .cb-dash-widget-card--text .cb-dash-widget-body--text { + padding-right: 52px; + padding-top: 8px; +} + +.cb-dash-prose.cb-dash-prose--bare { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + padding: 0; + background: transparent; + border: none; + border-radius: 0; + box-shadow: none; + height: auto; +} + +.cb-dash-shell.cb-theme-dark .cb-dash-prose.cb-dash-prose--bare { + background: transparent; + border: none; } .cb-dash-shell.cb-theme-dark .cb-dash-prose {