UI improvement
This commit is contained in:
parent
460ebf3e30
commit
3fbe6f52e7
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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'
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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() ?>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
<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 & 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</label>
|
||||
<label class="form-check-label small" for="cbChkDashPdfPageBreaks">Page breaks in PDF</label>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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 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() ?>
|
||||
|
||||
|
||||
@ -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="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 & 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</label>
|
||||
<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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user