GWM : chart improvemnet

This commit is contained in:
Gowtham M 2026-03-31 18:34:58 +05:30
parent f9f935e78e
commit 948553aac7
8 changed files with 608 additions and 286 deletions

View File

@ -101,6 +101,7 @@ class DashboardController extends BaseController
], $charts),
'urls' => [
'chartData' => rtrim(base_url(), '/') . '/chart/',
'chartEdit' => rtrim(base_url(), '/') . '/chart/edit/',
'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout',
'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget',
'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',

View File

@ -8,13 +8,39 @@ namespace App\Libraries;
class ChartRenderer
{
/** @var array<string, array<int, string>> */
// private const PALETTES = [
// 'mono' => ['#334155', '#475569', '#64748b', '#94a3b8', '#cbd5e1'],
// 'ocean' => ['#36a2eb', '#4bc0c0', '#a8e6ff', '#5b8ff9', '#2f7ed8'],
// 'forest' => ['#22c55e', '#16a34a', '#86efac', '#65a30d', '#14532d'],
// 'sunset' => ['#ff9f40', '#ffd166', '#f97316', '#fb7185', '#ef4444'],
// 'slate' => ['#0f172a', '#1e293b', '#334155', '#475569', '#64748b'],
// 'violet' => ['#8b5cf6', '#a78bfa', '#c4b5fd', '#d946ef', '#7c3aed'],
// ];
private const PALETTES = [
'mono' => ['#334155', '#475569', '#64748b', '#94a3b8', '#cbd5e1'],
'ocean' => ['#36a2eb', '#4bc0c0', '#a8e6ff', '#5b8ff9', '#2f7ed8'],
'forest' => ['#22c55e', '#16a34a', '#86efac', '#65a30d', '#14532d'],
'sunset' => ['#ff9f40', '#ffd166', '#f97316', '#fb7185', '#ef4444'],
'slate' => ['#0f172a', '#1e293b', '#334155', '#475569', '#64748b'],
'violet' => ['#8b5cf6', '#a78bfa', '#c4b5fd', '#d946ef', '#7c3aed'],
'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'],
];
/**

View File

@ -61,36 +61,34 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<input type="hidden" name="value_field" :value="valueField">
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h">
<span class="fw-bold">Query selector</span>
<div class="d-flex gap-2 align-items-center">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="cbAutoRun" x-model="autoRunPreview">
<label class="form-check-label small" for="cbAutoRun">Auto-run preview</label>
</div>
<button type="button" class="btn btn-dark btn-sm" @click.prevent="runPreview()" :disabled="!savedQueryId || previewLoading">
<span x-show="!previewLoading">Run</span>
<span x-show="previewLoading" class="spinner-border spinner-border-sm"></span>
</button>
</div>
</div>
<div class="cb-cb-card-b">
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label small fw-semibold">Saved query</label>
<div class="cb-cb-card-b cb-cb-query-compact">
<div class="cb-cb-query-row">
<div class="cb-cb-query-main">
<label class="form-label small fw-semibold mb-1">Saved query</label>
<select class="form-select" x-model.number="savedQueryId" @change="onQueryChange()" required>
<option value=""> Select a query </option>
<?php foreach ($queries as $q): ?>
<option value="<?= (int) $q['id'] ?>"><?= esc($q['name']) ?> (<?= esc($q['data_source_name'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
<p class="small text-muted mt-2 mb-0">Data source comes from the query automatically.</p>
</div>
<div class="col-lg-6" x-show="variableDefs.length" x-cloak>
<div class="p-3 rounded-3 bg-light border h-100">
<div class="small fw-bold mb-2">Query variables</div>
<div class="cb-cb-query-actions">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="cbAutoRun" x-model="autoRunPreview">
<label class="form-check-label small" for="cbAutoRun">Auto-run preview</label>
</div>
<button type="button" class="btn btn-dark btn-sm" @click.prevent="runPreview()" :disabled="!savedQueryId || previewLoading">
<span x-show="!previewLoading">Run</span>
<span x-show="previewLoading" class="spinner-border spinner-border-sm"></span>
</button>
</div>
</div>
<div class="cb-cb-query-vars mt-2" x-show="variableDefs.length" x-cloak>
<div class="p-2 rounded-3 bg-light border">
<div class="small fw-bold mb-2">Query variables</div>
<div class="row g-2">
<template x-for="v in variableDefs" :key="v.name">
<div class="mb-2">
<div class="col-md-6">
<label class="form-label small mb-0" x-text="v.label || v.name"></label>
<input type="text" class="form-control form-control-sm" :placeholder="v.default_value || ''" x-model="variables[v.name]" @input="queueRenderPreview()">
</div>
@ -102,117 +100,221 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div>
</div>
<div class="row g-3 align-items-start">
<div class="row g-3 align-items-start cb-cb-main-row">
<div class="col-lg-5">
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h"><span class="fw-bold">Recommended charts</span></div>
<div class="cb-cb-card-b">
<div class="d-flex flex-wrap gap-2" x-show="recommendedTypes.length" x-cloak>
<template x-for="rid in recommendedTypes" :key="rid">
<button type="button" class="cb-cb-type cb-cb-type--compact" :class="{ selected: chartType === rid }" @click="selectChartType(rid)">
<span x-text="labelForType(rid)"></span>
</button>
</template>
</div>
<p class="small text-muted mb-0" x-show="!recommendedTypes.length">Run query once to get chart recommendations based on your columns.</p>
<details class="mt-3">
<summary class="small fw-semibold">All chart types</summary>
<div class="cb-cb-type-grid mt-2">
<template x-for="t in chartTypes" :key="t.id">
<button type="button" class="cb-cb-type" :class="{ selected: chartType === t.id }" @click="selectChartType(t.id)">
<span class="cb-cb-type-ico" x-text="t.icon"></span>
<span x-text="t.label"></span>
</button>
<div class="cb-cb-card cb-cb-config-card">
<div class="cb-cb-config-tabs">
<button type="button" class="cb-cb-tab-btn" :class="{ active: configTab === 'data' }" @click="configTab = 'data'">Data</button>
<button type="button" class="cb-cb-tab-btn" :class="{ active: configTab === 'display' }" @click="configTab = 'display'">Display</button>
</div>
<div class="cb-cb-card-b cb-cb-config-body">
<div x-show="configTab === 'data'" x-cloak>
<div class="cb-cb-section mb-3">
<div class="small fw-bold mb-2">Recommended charts</div>
<div class="d-flex flex-wrap gap-2" x-show="recommendedTypes.length" x-cloak>
<template x-for="rid in recommendedTypes" :key="rid">
<button type="button" class="cb-cb-type cb-cb-type--compact" :class="{ selected: chartType === rid }" @click="selectChartType(rid)">
<span x-text="labelForType(rid)"></span>
</button>
</template>
</div>
<p class="small text-muted mb-0" x-show="!recommendedTypes.length">Run query once to get chart recommendations based on your columns.</p>
<details class="mt-2">
<summary class="small fw-semibold">All chart types</summary>
<div class="cb-cb-type-grid mt-2">
<template x-for="t in chartTypes" :key="t.id">
<button type="button" class="cb-cb-type" :class="{ selected: chartType === t.id }" @click="selectChartType(t.id)">
<span class="cb-cb-type-ico" x-text="t.icon"></span>
<span x-text="t.label"></span>
</button>
</template>
</div>
</details>
<div class="cb-cb-hint mt-2"><span x-text="typeHint()"></span></div>
</div>
<div class="cb-cb-section">
<div class="small fw-bold mb-2">Field mapping</div>
<template x-if="!columns.length">
<p class="text-muted small mb-0">Run query first to load columns and enable mapping.</p>
</template>
<template x-if="columns.length">
<div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">X axis</span><div class="text-muted small">Category / date</div></div>
<select class="form-select form-select-sm" x-model="xField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">Y axis</span><div class="text-muted small">Numeric values</div></div>
<select class="form-select form-select-sm" x-model="yField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="showGroup()">
<div><span class="fw-semibold small">Group / series</span><div class="text-muted small">Optional split</div></div>
<select class="form-select form-select-sm" x-model="groupField" @change="queueRenderPreview()">
<option value=""> None </option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'bubble'">
<div><span class="fw-semibold small">Bubble size (Z)</span></div>
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'gauge' || chartType === 'kpi_card'">
<div><span class="fw-semibold small">Value column</span></div>
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'combo'">
<div><span class="fw-semibold small">Line series (2nd metric)</span></div>
<select class="form-select form-select-sm" x-model="display.secondary_y_field" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
</div>
</template>
</div>
</details>
<div class="cb-cb-hint mt-3"><span x-text="typeHint()"></span></div>
</div>
</div>
</div>
<div class="cb-cb-card mb-3">
<div class="cb-cb-card-h"><span class="fw-bold">Field mapping</span></div>
<div class="cb-cb-card-b">
<template x-if="!columns.length">
<p class="text-muted small mb-0">Run query first to load columns and enable mapping.</p>
</template>
<template x-if="columns.length">
<div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">X axis</span><div class="text-muted small">Category / date</div></div>
<select class="form-select form-select-sm" x-model="xField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
<div x-show="configTab === 'display'" x-cloak>
<div class="cb-cb-section mb-3">
<div class="small fw-bold mb-2">Basic style</div>
<div class="mb-2">
<label class="form-label small">Chart title</label>
<input type="text" class="form-control form-control-sm" x-model="display.title" @input="queueRenderPreview()" placeholder="Optional">
</div>
<div class="mb-2">
<label class="form-label small">Number format</label>
<select class="form-select form-select-sm" x-model="display.number_format" @change="queueRenderPreview()">
<option value="auto">Auto</option>
<option value="inr"> INR</option>
<option value="usd">$ USD</option>
<option value="percent">%</option>
<option value="compact">K / M / B</option>
</select>
</div>
<div class="cb-cb-fm-row" x-show="needsXY()">
<div><span class="fw-semibold small">Y axis</span><div class="text-muted small">Numeric values</div></div>
<select class="form-select form-select-sm" x-model="yField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="lg" x-model="display.show_legend" @change="queueRenderPreview()">
<label class="form-check-label small" for="lg">Legend</label>
</div>
<div class="cb-cb-fm-row" x-show="showGroup()">
<div><span class="fw-semibold small">Group / series</span><div class="text-muted small">Optional split</div></div>
<select class="form-select form-select-sm" x-model="groupField" @change="queueRenderPreview()">
<option value=""> None </option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="dl" x-model="display.show_data_labels" @change="queueRenderPreview()">
<label class="form-check-label small" for="dl">Data labels</label>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'bubble'">
<div><span class="fw-semibold small">Bubble size (Z)</span></div>
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'gauge' || chartType === 'kpi_card'">
<div><span class="fw-semibold small">Value column</span></div>
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div>
<div class="cb-cb-fm-row" x-show="chartType === 'combo'">
<div><span class="fw-semibold small">Line series (2nd metric)</span></div>
<select class="form-select form-select-sm" x-model="display.secondary_y_field" @change="queueRenderPreview()">
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="gr" x-model="display.show_grid" @change="queueRenderPreview()">
<label class="form-check-label small" for="gr">Grid lines</label>
</div>
</div>
</template>
</div>
</div>
<div class="cb-cb-card">
<div class="cb-cb-card-h"><span class="fw-bold">Basic style</span></div>
<div class="cb-cb-card-b">
<div class="mb-2">
<label class="form-label small">Chart title</label>
<input type="text" class="form-control form-control-sm" x-model="display.title" @input="queueRenderPreview()" placeholder="Optional">
</div>
<div class="mb-2">
<label class="form-label small">Number format</label>
<select class="form-select form-select-sm" x-model="display.number_format" @change="queueRenderPreview()">
<option value="auto">Auto</option>
<option value="inr"> INR</option>
<option value="usd">$ USD</option>
<option value="percent">%</option>
<option value="compact">K / M / B</option>
</select>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="lg" x-model="display.show_legend" @change="queueRenderPreview()">
<label class="form-check-label small" for="lg">Legend</label>
</div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox" id="dl" x-model="display.show_data_labels" @change="queueRenderPreview()">
<label class="form-check-label small" for="dl">Data labels</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="gr" x-model="display.show_grid" @change="queueRenderPreview()">
<label class="form-check-label small" for="gr">Grid lines</label>
<div class="cb-cb-section">
<div class="small fw-bold mb-2">Advanced options</div>
<button type="button" class="cb-cb-advanced-toggle" data-bs-toggle="collapse" data-bs-target="#cbAdvancedStyleInline" aria-expanded="false" aria-controls="cbAdvancedStyleInline">
<span class="material-symbols-outlined">tune</span>
<span>Advanced style controls</span>
</button>
<div id="cbAdvancedStyleInline" class="collapse mt-2">
<div class="row g-3">
<div class="col-12">
<label class="form-label small">Subtitle</label>
<input type="text" class="form-control form-control-sm" x-model="display.subtitle" @input="queueRenderPreview()">
</div>
<div class="col-6">
<label class="form-label small">Decimal places</label>
<select class="form-select form-select-sm" x-model.number="display.decimal_places" @change="queueRenderPreview()">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
<div class="col-6">
<label class="form-label small">Colors</label>
<select class="form-select form-select-sm" x-model="display.color_mode" @change="ensurePaletteColors(); queueRenderPreview();">
<option value="preset">Theme</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
<div class="row g-3 mt-1" x-show="display.color_mode === 'preset'">
<div class="col-12">
<label class="form-label small">Palette</label>
<select class="form-select form-select-sm" x-model="display.palette" @change="queueRenderPreview()">
<option value="mono">Mono</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="slate">Slate</option>
<option value="violet">Violet</option>
<option value="rose">Rose</option>
<option value="teal">Teal</option>
<option value="amber">Amber</option>
<option value="indigo">Indigo</option>
<option value="cool_gray">Cool Gray</option>
</select>
</div>
</div>
<div class="mt-2" x-show="display.color_mode === 'custom'">
<label class="form-label small">Swatches</label>
<div class="d-flex flex-wrap gap-2 align-items-center">
<template x-for="idx in [0,1,2,3,4]" :key="idx">
<input type="color" class="form-control form-control-color cb-cb-color-swatch" :title="'Color ' + (idx + 1)" x-model="display.palette_colors[idx]" @input="queueRenderPreview()">
</template>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-6" x-show="needsXY()">
<label class="form-label small">Y min</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_min" @input="queueRenderPreview()" placeholder="Auto">
</div>
<div class="col-6" x-show="needsXY()">
<label class="form-label small">Y max</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_max" @input="queueRenderPreview()" placeholder="Auto">
</div>
</div>
<div class="small fw-semibold text-muted mt-3 mb-2">Chart behavior</div>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advLegend" x-model="display.show_legend" @change="queueRenderPreview()">
<label class="form-check-label small" for="advLegend">Legend</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advLabels" x-model="display.show_data_labels" @change="queueRenderPreview()">
<label class="form-check-label small" for="advLabels">Data labels</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advGrid" x-model="display.show_grid" @change="queueRenderPreview()">
<label class="form-check-label small" for="advGrid">Grid lines</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="advSmooth" x-model="display.smooth" @change="queueRenderPreview()">
<label class="form-check-label small" for="advSmooth">Smooth curves</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="advStepline" x-model="display.stepline" @change="queueRenderPreview()">
<label class="form-check-label small" for="advStepline">Step line</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'bar'">
<input class="form-check-input" type="checkbox" id="advHorizontalBar" x-model="display.horizontal_bar" @change="queueRenderPreview()">
<label class="form-check-label small" for="advHorizontalBar">Horizontal bars</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'bar' || chartType === 'area' || chartType === 'line' || chartType === 'spline' || chartType === 'stepline'">
<input class="form-check-input" type="checkbox" id="advStacked" x-model="display.stacked" @change="queueRenderPreview()">
<label class="form-check-label small" for="advStacked">Stacked</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -224,17 +326,23 @@ $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" @click.prevent="renderLivePreview()" :disabled="renderLoading || !columns.length">Refresh</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>
<?php if ($mode === 'edit' && ! empty($cRow['id'])): ?>
<button type="button" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" id="cbExportPng" title="Export PNG" aria-label="Export PNG">
<span class="material-symbols-outlined" style="font-size:18px;">image</span>
</button>
<a class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=csv') ?>" title="Export CSV" aria-label="Export CSV">
<span class="material-symbols-outlined" style="font-size:18px;">description</span>
</a>
<a class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center" style="width:36px;height:32px;" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=excel') ?>" title="Export Excel" aria-label="Export Excel">
<span class="material-symbols-outlined" style="font-size:18px;">table_view</span>
</a>
<?php endif; ?>
</div>
</div>
<div class="cb-cb-card-b">
<?php if ($mode === 'edit' && ! empty($cRow['id'])): ?>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbExportPng">Export PNG</button>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=csv') ?>">Export CSV</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('chart/' . (int) $cRow['id'] . '/export?format=excel') ?>">Export Excel</a>
</div>
<?php endif; ?>
<div id="cb-apex-preview" class="cb-cb-preview-host p-2"></div>
<p class="text-danger small mt-2 mb-0" x-show="renderError" x-text="renderError"></p>
</div>
@ -243,116 +351,13 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div>
<div class="cb-cb-card mt-3">
<div class="cb-cb-card-h"><span class="fw-bold">Advanced settings & save</span></div>
<div class="cb-cb-card-h"><span class="fw-bold">Save chart</span></div>
<div class="cb-cb-card-b">
<div class="accordion" id="cbAdvancedAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#cbAdvancedStyle">Advanced style</button>
</h2>
<div id="cbAdvancedStyle" class="accordion-collapse collapse" data-bs-parent="#cbAdvancedAccordion">
<div class="accordion-body">
<div class="row g-3">
<div class="col-lg-6">
<label class="form-label small">Subtitle</label>
<input type="text" class="form-control form-control-sm" x-model="display.subtitle" @input="queueRenderPreview()">
</div>
<div class="col-lg-3">
<label class="form-label small">Decimal places</label>
<select class="form-select form-select-sm" x-model.number="display.decimal_places" @change="queueRenderPreview()">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
<div class="col-lg-3">
<label class="form-label small">Colors</label>
<select class="form-select form-select-sm" x-model="display.color_mode" @change="ensurePaletteColors(); queueRenderPreview();">
<option value="preset">Theme</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
<div class="row g-3 mt-1" x-show="display.color_mode === 'preset'">
<div class="col-lg-4">
<label class="form-label small">Palette</label>
<select class="form-select form-select-sm" x-model="display.palette" @change="queueRenderPreview()">
<option value="mono">Mono</option>
<option value="ocean">Ocean</option>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
<option value="slate">Slate</option>
<option value="violet">Violet</option>
</select>
</div>
</div>
<div class="mt-2" x-show="display.color_mode === 'custom'">
<label class="form-label small">Swatches</label>
<div class="d-flex flex-wrap gap-2 align-items-center">
<template x-for="idx in [0,1,2,3,4]" :key="idx">
<input type="color" class="form-control form-control-color cb-cb-color-swatch" :title="'Color ' + (idx + 1)" x-model="display.palette_colors[idx]" @input="queueRenderPreview()">
</template>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-6" x-show="needsXY()">
<label class="form-label small">Y min</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_min" @input="queueRenderPreview()" placeholder="Auto">
</div>
<div class="col-md-6" x-show="needsXY()">
<label class="form-label small">Y max</label>
<input type="text" class="form-control form-control-sm" x-model="display.y_max" @input="queueRenderPreview()" placeholder="Auto">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12">
<div class="small fw-semibold text-muted mb-2">Chart behavior</div>
<div class="d-flex flex-wrap gap-3">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advLegend" x-model="display.show_legend" @change="queueRenderPreview()">
<label class="form-check-label small" for="advLegend">Legend</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advLabels" x-model="display.show_data_labels" @change="queueRenderPreview()">
<label class="form-check-label small" for="advLabels">Data labels</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="advGrid" x-model="display.show_grid" @change="queueRenderPreview()">
<label class="form-check-label small" for="advGrid">Grid lines</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="advSmooth" x-model="display.smooth" @change="queueRenderPreview()">
<label class="form-check-label small" for="advSmooth">Smooth curves</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'line' || chartType === 'area'">
<input class="form-check-input" type="checkbox" id="advStepline" x-model="display.stepline" @change="queueRenderPreview()">
<label class="form-check-label small" for="advStepline">Step line</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'bar'">
<input class="form-check-input" type="checkbox" id="advHorizontalBar" x-model="display.horizontal_bar" @change="queueRenderPreview()">
<label class="form-check-label small" for="advHorizontalBar">Horizontal bars</label>
</div>
<div class="form-check form-switch mb-0" x-show="chartType === 'bar' || chartType === 'area' || chartType === 'line' || chartType === 'spline' || chartType === 'stepline'">
<input class="form-check-input" type="checkbox" id="advStacked" x-model="display.stacked" @change="queueRenderPreview()">
<label class="form-check-label small" for="advStacked">Stacked</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-lg-6">
<div class="col-lg-8">
<label class="form-label small">Chart name</label>
<input type="text" class="form-control" name="name" x-model="chartName" required minlength="2" maxlength="200">
</div>
<div class="col-lg-6">
<label class="form-label small">Description</label>
<textarea class="form-control form-control-sm" name="description" rows="2" x-model="chartDescription"></textarea>
</div>
<div class="col-lg-4">
<label class="form-label small">Auto-refresh (dashboards)</label>
<select class="form-select form-select-sm" name="refresh_interval" x-model.number="refreshInterval">
@ -365,8 +370,14 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div>
</div>
<div class="d-flex justify-content-end mt-3 pt-2 border-top">
<button type="submit" class="btn btn-dark"><?= $mode === 'edit' ? 'Update chart' : 'Save chart' ?></button>
<div class="row g-3 mt-1 align-items-end">
<div class="col-lg-9">
<label class="form-label small">Description</label>
<textarea class="form-control form-control-sm" name="description" rows="2" x-model="chartDescription"></textarea>
</div>
<div class="col-lg-3 d-grid">
<button type="submit" class="btn btn-dark"><?= $mode === 'edit' ? 'Update chart' : 'Save chart' ?></button>
</div>
</div>
</div>
</div>

View File

@ -185,6 +185,7 @@
renderError: '',
renderMeta: '',
autoRunPreview: true,
configTab: 'data',
recommendedTypes: [],
_renderTimer: null,
chartTypes: [

View File

@ -270,7 +270,11 @@
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)
? ('<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 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;
if (w.widget_type === 'chart') {
const vars = await loadVariableDefs(w.saved_query_id);
@ -280,7 +284,7 @@
}
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span>' + rm + '</div>' +
'<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">' +
varsHtml +
'<div class="cb-dash-chart-inner position-relative">' +
@ -318,7 +322,7 @@
if (w.widget_type === 'text') {
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span>' + rm + '</div>' +
'<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>';
renderMarkdown(document.getElementById('cb-md-' + w.id), w.content || '');
return;
@ -329,7 +333,7 @@
const safeUrl = escapeHtml(w.content || '');
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span>' + rm + '</div>' +
'<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">' +
(safeUrl ? '<img class="cb-dash-img" src="' + safeUrl + '" alt="" style="object-fit:' + escapeHtml(fit) + '">' : '<p class="text-muted small">No image URL</p>') +
'</div></div>';
@ -342,7 +346,7 @@
const ev = cfg.end_var || 'date_to';
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span>' + rm + '</div>' +
'<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-filter-label">Date range → queries</div>' +
'<div class="row g-2">' +
@ -366,7 +370,7 @@
let optsHtml = opts.map((o) => '<option value="' + escapeHtml(String(o)) + '">' + escapeHtml(String(o)) + '</option>').join('');
mount.innerHTML =
'<div class="cb-dash-widget-card h-100">' +
'<div class="cb-dash-widget-head"><span class="text-truncate">' + escapeHtml(title) + '</span>' + rm + '</div>' +
'<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-filter-label">Dropdown · <code>' + escapeHtml(vn) + '</code></div>' +
'<select class="form-select form-select-sm cb-fdrop" data-fk="' + escapeHtml(vn) + '"><option value="">All</option>' + optsHtml + '</select>' +
@ -416,8 +420,6 @@
function setEditUi() {
if (READ_ONLY) return;
const editHint = document.getElementById('cbEditHint');
if (editHint) editHint.hidden = !editing;
document.getElementById('cbBtnSaveLayout')?.classList.toggle('d-none', !editing);
document.getElementById('cbBtnDiscard')?.classList.toggle('d-none', !editing || !layoutDirty);
const addW = document.getElementById('cbBtnAddWidget');
@ -427,16 +429,19 @@
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));
}
function initGrid() {
const el = document.getElementById('cbGridStack');
if (!el || typeof GridStack === 'undefined') return;
const cellHeight = 56;
el.style.setProperty('--cb-grid-cell-h', cellHeight + 'px');
grid = GridStack.init({
column: 12,
cellHeight: 76,
marginTop: 10,
marginBottom: 10,
cellHeight,
marginTop: 14,
marginBottom: 14,
marginLeft: 8,
marginRight: 8,
animate: true,
@ -445,6 +450,11 @@
}, el);
grid.on('change', () => { layoutDirty = true; setEditUi(); });
const setDraggingState = (on) => el.classList.toggle('cb-dash-dragging', !!on);
grid.on('dragstart', () => setDraggingState(true));
grid.on('dragstop', () => setDraggingState(false));
grid.on('resizestart', () => setDraggingState(true));
grid.on('resizestop', () => setDraggingState(false));
}
function toggleEdit() {

View File

@ -17,27 +17,31 @@ $themeClass = match ($theme) {
?>
<div class="cb-dash-shell <?= esc($themeClass) ?>" id="cbDashShell">
<div class="cb-dash-toolbar">
<div class="flex-grow-1 min-w-0">
<div class="cb-dash-toolbar-head flex-grow-1 min-w-0">
<h1><?= esc($dashboard['name']) ?></h1>
<div class="cb-dash-meta"><?= esc($dashboard['description'] ?? '') ?></div>
</div>
<span class="cb-dash-edit-hint d-none d-md-inline" id="cbEditHint" hidden>Drag corners to resize · drag card to move</span>
<div class="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 align-middle" style="font-size:18px;">edit_square</span>
<span id="cbBtnToggleEditLabel">Edit layout</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 align-middle" style="font-size:18px;">add_box</span> Add widget
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnFs" title="Fullscreen">
<span class="material-symbols-outlined" style="font-size:18px;">fullscreen</span>
</button>
<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>
<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>
</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>
</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>
</button>
<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>
</div>
</div>
</div>

View File

@ -165,6 +165,128 @@
.cb-cb-preview-card {
position: sticky;
top: calc(var(--topbar-height) + 14px);
display: flex;
flex-direction: column;
}
.cb-cb-config-card {
position: sticky;
top: calc(var(--topbar-height) + 14px);
display: flex;
flex-direction: column;
}
.cb-cb-main-row > .col-lg-5,
.cb-cb-main-row > .col-lg-7 {
display: flex;
}
.cb-cb-main-row .cb-cb-config-card,
.cb-cb-main-row .cb-cb-preview-card {
width: 100%;
height: calc(100vh - var(--topbar-height) - 90px);
min-height: 680px;
}
.cb-cb-config-tabs {
display: flex;
gap: 6px;
background: #fff;
padding: 8px 10px 0;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
}
.cb-cb-tab-btn {
border: 1px solid transparent;
border-bottom: 0;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 700;
padding: 8px 14px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-bottom: -1px;
}
.cb-cb-tab-btn.active {
color: #0f172a;
background: #fff;
border: 1px solid var(--cb-cb-border);
border-bottom-color: #fff;
box-shadow: inset 0 -1px 0 #fff;
}
.cb-cb-config-body {
border-top: 1px solid var(--cb-cb-border);
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.cb-cb-preview-card .cb-cb-card-b {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.cb-cb-preview-card #cb-apex-preview {
flex: 1 1 auto;
min-height: 0;
}
.cb-cb-section {
border: 1px solid #e7edf5;
border-radius: 10px;
padding: 12px;
}
.cb-cb-advanced-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #d8e0eb;
background: #f8fafc;
color: #334155;
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.cb-cb-advanced-toggle:hover {
background: #eef2f7;
border-color: #cbd5e1;
}
.cb-cb-advanced-toggle .material-symbols-outlined {
font-size: 16px;
}
.cb-cb-query-compact {
padding-top: 10px;
padding-bottom: 10px;
}
.cb-cb-query-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.cb-cb-query-main {
flex: 1 1 auto;
min-width: 240px;
}
.cb-cb-query-actions {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.cb-cb-hint {
@ -259,4 +381,34 @@
position: static;
top: auto;
}
.cb-cb-config-card {
position: static;
top: auto;
}
.cb-cb-main-row > .col-lg-5,
.cb-cb-main-row > .col-lg-7 {
display: block;
}
.cb-cb-main-row .cb-cb-config-card,
.cb-cb-main-row .cb-cb-preview-card {
height: auto;
}
.cb-cb-config-body {
max-height: none;
overflow: visible;
}
.cb-cb-query-row {
flex-wrap: wrap;
align-items: stretch;
}
.cb-cb-query-actions {
width: 100%;
justify-content: flex-end;
}
}

View File

@ -182,35 +182,95 @@
.cb-dash-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 14px 18px;
align-items: flex-start;
justify-content: space-between;
gap: 12px 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
border: 1px solid #e5e7eb;
border-radius: 16px;
border-radius: 14px;
margin-bottom: 18px;
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
}
.cb-dash-toolbar-head {
min-width: 220px;
max-width: 460px;
}
.cb-dash-toolbar h1 {
font-size: 1.15rem;
font-size: 1.08rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
line-height: 1.25;
}
.cb-dash-toolbar .cb-dash-meta {
font-size: 12px;
color: #64748b;
margin-top: 3px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cb-dash-toolbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px 10px;
margin-left: auto;
}
.cb-dash-toolbar-primary,
.cb-dash-toolbar-secondary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.cb-dash-toolbar-primary {
padding-right: 10px;
border-right: 1px solid #e5e7eb;
}
.cb-dash-toolbar .btn {
border-radius: 9px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1.1;
min-height: 34px;
padding-inline: 11px;
}
.cb-dash-toolbar .btn.btn-primary {
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.24);
}
.cb-btn-icon {
font-size: 17px;
line-height: 1;
}
.cb-dash-shell.cb-theme-dark .cb-dash-toolbar .cb-dash-meta {
color: #94a3b8;
}
.cb-dash-shell.cb-theme-dark .cb-dash-toolbar-primary {
border-right-color: rgba(148, 163, 184, 0.25);
}
.cb-dash-grid-wrap {
min-height: 400px;
border-radius: 14px;
transition: background 0.18s ease, box-shadow 0.18s ease;
}
/* Stretch widgets to the full GridStack cell so paired cards align visually */
@ -233,7 +293,8 @@
}
.cb-dash-widget-card {
height: 100%;
height: calc(100% - 12px);
margin: 6px;
background: #fff;
border: 1px solid #e8ecf1;
border-radius: 14px;
@ -254,6 +315,13 @@
color: #0f172a;
}
.cb-dash-widget-head-actions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.cb-dash-shell.cb-theme-dark .cb-dash-widget-head {
border-bottom-color: rgba(148, 163, 184, 0.15);
color: #f1f5f9;
@ -282,13 +350,23 @@
width: 100%;
}
.cb-dash-widget-edit,
.cb-dash-widget-remove {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: #94a3b8;
padding: 2px;
line-height: 1;
border-radius: 6px;
text-decoration: none;
}
.cb-dash-widget-edit:hover {
background: #eef2ff;
color: #4f46e5;
}
.cb-dash-widget-remove:hover {
@ -296,14 +374,6 @@
color: #dc2626;
}
.cb-dash-edit-hint {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6366f1;
}
.cb-dash-prose {
font-size: 14px;
line-height: 1.65;
@ -400,3 +470,50 @@ body.cb-dash-fs .cb-dash-shell {
.grid-stack.cb-dash-editing .grid-stack-item {
cursor: grab;
}
.grid-stack.cb-dash-editing.cb-dash-dragging {
background-image:
linear-gradient(to right, rgba(148, 163, 184, 0.22) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.22) 1px, transparent 1px);
background-size: calc(100% / 12) var(--cb-grid-cell-h, 56px);
background-position: 0 0;
background-color: rgba(248, 250, 252, 0.7);
border-radius: 14px;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.28);
}
.cb-dash-shell.cb-theme-dark .grid-stack.cb-dash-editing.cb-dash-dragging {
background-image:
linear-gradient(to right, rgba(148, 163, 184, 0.28) 1px, transparent 1px),
linear-gradient(to bottom, rgba(148, 163, 184, 0.28) 1px, transparent 1px);
background-color: rgba(15, 23, 42, 0.35);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.32);
}
.grid-stack .ui-resizable-handle.ui-resizable-se {
right: 12px !important;
bottom: 12px !important;
z-index: 5;
}
@media (max-width: 992px) {
.cb-dash-toolbar {
align-items: stretch;
}
.cb-dash-toolbar-head,
.cb-dash-toolbar-actions {
width: 100%;
max-width: none;
}
.cb-dash-toolbar-actions {
justify-content: flex-start;
margin-left: 0;
}
.cb-dash-toolbar-primary {
border-right: 0;
padding-right: 0;
}
}