UI improvement

This commit is contained in:
Gowtham M 2026-04-09 11:29:09 +05:30
parent 460ebf3e30
commit 3fbe6f52e7
10 changed files with 716 additions and 68 deletions

View File

@ -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');

View File

@ -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');

View File

@ -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'
],
];

View File

@ -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();

View File

@ -326,6 +326,9 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<span class="fw-bold">Live preview</span>
<div class="d-flex gap-2 align-items-center">
<span class="small text-muted" x-text="renderMeta"></span>
<button type="button" class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" id="cbPreviewFsBtn" title="Fullscreen preview" aria-label="Fullscreen preview">
<span class="material-symbols-outlined" id="cbPreviewFsIcon" style="font-size:18px;">open_in_full</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" title="Refresh preview" aria-label="Refresh preview" @click.prevent="renderLivePreview()" :disabled="renderLoading || !columns.length">
<span class="material-symbols-outlined" style="font-size:18px;">refresh</span>
</button>
@ -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();
}
});
})();
</script>
<?= $this->endSection() ?>

View File

@ -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)
? ('<a class="cb-dash-widget-edit' + (editing ? '' : ' d-none') + '" href="' + escapeHtml(boot.urls.chartEdit + w.chart_id) + '" target="_blank" rel="noopener noreferrer" aria-label="Edit chart"><span class="material-symbols-outlined" style="font-size:18px;">edit</span></a>')
: '';
const textEditBtn = (!READ_ONLY && w.widget_type === 'text' && boot.urls && boot.urls.updateWidget)
? ('<button type="button" class="cb-dash-widget-edit cb-dash-widget-edit-text' + (editing ? '' : ' d-none') + '" data-edit-text="' + w.id + '" aria-label="Edit text widget"><span class="material-symbols-outlined" style="font-size:18px;">edit</span></button>')
: '';
const rm = READ_ONLY ? '' : ('<button type="button" class="cb-dash-widget-remove' + (editing ? '' : ' d-none') + '" data-remove="' + w.id + '" aria-label="Remove"><span class="material-symbols-outlined" style="font-size:18px;">close</span></button>');
const actions = editLink + rm;
const actions = chartEditLink + textEditBtn + rm;
if (w.widget_type === 'chart') {
const exportPng = '<button type="button" class="cb-dash-widget-export cb-dash-export-png" data-widget-id="' + w.id + '" title="Export PNG" aria-label="Export PNG"><span class="material-symbols-outlined" style="font-size:18px;">image</span></button>';
@ -535,7 +538,7 @@
const exportExcel = canExcel
? '<a class="cb-dash-widget-export" href="' + escapeHtml(excelHref) + '" title="Export Excel" aria-label="Export Excel" target="_blank" rel="noopener noreferrer"><span class="material-symbols-outlined" style="font-size:18px;">table_view</span></a>'
: '';
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)
? ('<div class="cb-dash-widget-text-actions cb-dash-widget-text-actions--overlay">' + actions + '</div>')
: '';
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span><div class="cb-dash-widget-head-actions">' + actions + '</div></div>' +
'<div class="cb-dash-widget-body"><div class="cb-dash-prose" id="cb-md-' + w.id + '"></div></div></div>';
'<div class="cb-dash-widget-card cb-dash-widget-card--text h-100">' +
overlayHtml +
'<div class="cb-dash-widget-body cb-dash-widget-body--text"><div class="cb-dash-prose cb-dash-prose--bare" id="cb-md-' + w.id + '"></div></div></div>';
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 = '<div class="cb-dash-widget-mount" id="cb-w-' + nw.id + '" data-widget-id="' + nw.id + '"></div>';
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);

View File

@ -22,33 +22,54 @@ $themeClass = match ($theme) {
<div class="cb-dash-meta"><?= esc($dashboard['description'] ?? '') ?></div>
</div>
<div class="cb-dash-toolbar-actions">
<div class="cb-dash-toolbar-primary d-flex flex-wrap align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="cbBtnDiscard" title="Reload layout from server">Discard</button>
<button type="button" class="btn btn-sm btn-primary d-none" id="cbBtnSaveLayout">Save layout</button>
<button type="button" class="btn btn-sm btn-dark" id="cbBtnToggleEdit">
<span class="material-symbols-outlined cb-btn-icon">edit_square</span>
<span id="cbBtnToggleEditLabel">Edit layout</span>
<div class="cb-dash-toolbar-primary d-flex flex-wrap align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary d-none cb-dash-icon-btn" id="cbBtnDiscard" title="Discard unsaved layout changes" aria-label="Discard unsaved layout changes">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">undo</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnAddWidget" data-bs-toggle="modal" data-bs-target="#modalAddWidget" hidden>
<span class="material-symbols-outlined cb-btn-icon">add_box</span>
<span>Add widget</span>
<button type="button" class="btn btn-sm btn-primary d-none cb-dash-icon-btn" id="cbBtnSaveLayout" title="Save layout" aria-label="Save layout">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">save</span>
</button>
<button type="button" class="btn btn-sm btn-dark cb-dash-icon-btn" id="cbBtnToggleEdit" title="Edit layout" aria-label="Edit layout">
<span class="material-symbols-outlined cb-btn-icon cb-dash-toggle-icon cb-dash-toggle-icon--edit" aria-hidden="true">edit_square</span>
<span class="material-symbols-outlined cb-btn-icon cb-dash-toggle-icon cb-dash-toggle-icon--close d-none" aria-hidden="true">close</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn" id="cbBtnAddWidget" data-bs-toggle="modal" data-bs-target="#modalAddWidget" hidden title="Add widget" aria-label="Add widget">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">add_box</span>
</button>
</div>
<div class="cb-dash-toolbar-secondary d-flex flex-wrap align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnFs" title="Fullscreen">
<span class="material-symbols-outlined cb-btn-icon">fullscreen</span>
<div class="cb-dash-toolbar-secondary d-flex flex-wrap align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn cb-dash-fs-btn" id="cbBtnFs" title="Fullscreen" aria-label="Fullscreen">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">open_in_full</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnExportDashPdf" title="Export dashboard PDF (A4)">
<span class="material-symbols-outlined cb-btn-icon">picture_as_pdf</span>
<span>Export PDF</span>
</button>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="cbChkDashPdfPageBreaks" checked>
<label class="form-check-label small" for="cbChkDashPdfPageBreaks">Page breaks</label>
<div class="dropdown">
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn dropdown-toggle-hide-caret" id="cbDashPdfMenuBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" title="PDF export &amp; page breaks" aria-label="PDF export and page break options">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">picture_as_pdf</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0 rounded-3 p-2 cb-dash-pdf-dd" aria-labelledby="cbDashPdfMenuBtn">
<li>
<button type="button" class="dropdown-item rounded-2 d-flex align-items-center gap-2 fw-semibold" id="cbBtnExportDashPdf">
<span class="material-symbols-outlined flex-shrink-0" style="font-size:20px;">picture_as_pdf</span>
Export PDF
</button>
</li>
<li><hr class="dropdown-divider my-2"></li>
<li class="px-2 pb-1">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="cbChkDashPdfPageBreaks" checked>
<label class="form-check-label small" for="cbChkDashPdfPageBreaks">Page breaks in PDF</label>
</div>
</li>
</ul>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="dashboard" data-resource-id="<?= $did ?>">Share</button>
<a href="<?= base_url('dashboard/settings/' . $did) ?>" class="btn btn-sm btn-outline-secondary">Settings</a>
<a href="<?= base_url('dashboard') ?>" class="btn btn-sm btn-light border">All boards</a>
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="dashboard" data-resource-id="<?= $did ?>" title="Share" aria-label="Share dashboard">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">share</span>
</button>
<a href="<?= base_url('dashboard/settings/' . $did) ?>" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn" title="Dashboard settings" aria-label="Dashboard settings">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">settings</span>
</a>
<a href="<?= base_url('dashboard') ?>" class="btn btn-sm btn-light border cb-dash-icon-btn" title="Back to all dashboards" aria-label="Back to all dashboards">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">arrow_back</span>
</a>
</div>
</div>
</div>
@ -151,6 +172,26 @@ $themeClass = match ($theme) {
</div>
</div>
<div class="modal fade" id="modalEditTextWidget" tabindex="-1" aria-labelledby="modalEditTextWidgetLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg rounded-4">
<div class="modal-header border-0">
<h2 class="modal-title h5 fw-bold" id="modalEditTextWidgetLabel">Edit text widget</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="cbEditTextWidgetId" value="">
<label class="form-label fw-semibold" for="cbEditTextMarkdown">Markdown</label>
<textarea class="form-control font-monospace" id="cbEditTextMarkdown" rows="10" placeholder="## Section title&#10;Your **markdown** here."></textarea>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-light border" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-dark" id="cbBtnSaveTextWidget">Save</button>
</div>
</div>
</div>
</div>
<script id="cb-dash-boot" type="application/json"><?= json_encode($dashBoot, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?></script>
<?= $this->endSection() ?>

View File

@ -20,18 +20,30 @@ $themeClass = match ($theme) {
<h1><?= esc($dashboard['name']) ?></h1>
<div class="cb-dash-meta"><?= esc($dashboard['description'] ?? '') ?></div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnExportDashPdf" title="Export dashboard PDF (A4)">
<span class="material-symbols-outlined cb-btn-icon">picture_as_pdf</span>
<span>Export PDF</span>
<div class="d-flex flex-wrap align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary d-none cb-dash-icon-btn cb-dash-fs-btn" id="cbBtnFs" title="Fullscreen" aria-label="Fullscreen">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">open_in_full</span>
</button>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="cbChkDashPdfPageBreaks" checked>
<label class="form-check-label small" for="cbChkDashPdfPageBreaks">Page breaks</label>
<div class="dropdown">
<button type="button" class="btn btn-sm btn-outline-secondary cb-dash-icon-btn dropdown-toggle-hide-caret" id="cbDashPdfMenuBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" title="PDF export &amp; page breaks" aria-label="PDF export and page break options">
<span class="material-symbols-outlined cb-btn-icon" aria-hidden="true">picture_as_pdf</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0 rounded-3 p-2 cb-dash-pdf-dd" aria-labelledby="cbDashPdfMenuBtn">
<li>
<button type="button" class="dropdown-item rounded-2 d-flex align-items-center gap-2 fw-semibold" id="cbBtnExportDashPdf">
<span class="material-symbols-outlined flex-shrink-0" style="font-size:20px;">picture_as_pdf</span>
Export PDF
</button>
</li>
<li><hr class="dropdown-divider my-2"></li>
<li class="px-2 pb-1">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="cbChkDashPdfPageBreaks" checked>
<label class="form-check-label small" for="cbChkDashPdfPageBreaks">Page breaks in PDF</label>
</div>
</li>
</ul>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="cbBtnFs" title="Fullscreen">
<span class="material-symbols-outlined" style="font-size:18px;">fullscreen</span>
</button>
</div>
</div>

View File

@ -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);

View File

@ -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;
}
/*
* GridStacks 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 dont 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 {