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), ], $charts),
'urls' => [ 'urls' => [
'chartData' => rtrim(base_url(), '/') . '/chart/', 'chartData' => rtrim(base_url(), '/') . '/chart/',
'chartEdit' => rtrim(base_url(), '/') . '/chart/edit/',
'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout', 'saveLayout' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/layout',
'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget', 'addWidget' => rtrim(base_url(), '/') . '/dashboard/' . $id . '/widget',
'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/', 'delWidget' => rtrim(base_url(), '/') . '/dashboard/widget/',

View File

@ -8,13 +8,39 @@ namespace App\Libraries;
class ChartRenderer class ChartRenderer
{ {
/** @var array<string, array<int, string>> */ /** @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 = [ private const PALETTES = [
'mono' => ['#334155', '#475569', '#64748b', '#94a3b8', '#cbd5e1'],
'ocean' => ['#36a2eb', '#4bc0c0', '#a8e6ff', '#5b8ff9', '#2f7ed8'], 'mono' => ['#020617','#0f172a','#1e293b','#334155','#475569','#64748b','#94a3b8','#cbd5e1','#e2e8f0','#f1f5f9','#f8fafc','#ffffff'],
'forest' => ['#22c55e', '#16a34a', '#86efac', '#65a30d', '#14532d'],
'sunset' => ['#ff9f40', '#ffd166', '#f97316', '#fb7185', '#ef4444'], 'ocean' => ['#075985','#0369a1','#0284c7','#0ea5e9','#38bdf8','#7dd3fc','#bae6fd','#e0f2fe','#f0f9ff','#e6f6ff','#cceeff','#99ddff'],
'slate' => ['#0f172a', '#1e293b', '#334155', '#475569', '#64748b'],
'violet' => ['#8b5cf6', '#a78bfa', '#c4b5fd', '#d946ef', '#7c3aed'], '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"> <input type="hidden" name="value_field" :value="valueField">
<div class="cb-cb-card mb-3"> <div class="cb-cb-card mb-3">
<div class="cb-cb-card-h"> <div class="cb-cb-card-b cb-cb-query-compact">
<span class="fw-bold">Query selector</span> <div class="cb-cb-query-row">
<div class="d-flex gap-2 align-items-center"> <div class="cb-cb-query-main">
<div class="form-check form-switch mb-0"> <label class="form-label small fw-semibold mb-1">Saved query</label>
<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>
<select class="form-select" x-model.number="savedQueryId" @change="onQueryChange()" required> <select class="form-select" x-model.number="savedQueryId" @change="onQueryChange()" required>
<option value=""> Select a query </option> <option value=""> Select a query </option>
<?php foreach ($queries as $q): ?> <?php foreach ($queries as $q): ?>
<option value="<?= (int) $q['id'] ?>"><?= esc($q['name']) ?> (<?= esc($q['data_source_name'] ?? '') ?>)</option> <option value="<?= (int) $q['id'] ?>"><?= esc($q['name']) ?> (<?= esc($q['data_source_name'] ?? '') ?>)</option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<p class="small text-muted mt-2 mb-0">Data source comes from the query automatically.</p>
</div> </div>
<div class="col-lg-6" x-show="variableDefs.length" x-cloak> <div class="cb-cb-query-actions">
<div class="p-3 rounded-3 bg-light border h-100"> <div class="form-check form-switch mb-0">
<div class="small fw-bold mb-2">Query variables</div> <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"> <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> <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()"> <input type="text" class="form-control form-control-sm" :placeholder="v.default_value || ''" x-model="variables[v.name]" @input="queueRenderPreview()">
</div> </div>
@ -102,117 +100,221 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div> </div>
</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="col-lg-5">
<div class="cb-cb-card mb-3"> <div class="cb-cb-card cb-cb-config-card">
<div class="cb-cb-card-h"><span class="fw-bold">Recommended charts</span></div> <div class="cb-cb-config-tabs">
<div class="cb-cb-card-b"> <button type="button" class="cb-cb-tab-btn" :class="{ active: configTab === 'data' }" @click="configTab = 'data'">Data</button>
<div class="d-flex flex-wrap gap-2" x-show="recommendedTypes.length" x-cloak> <button type="button" class="cb-cb-tab-btn" :class="{ active: configTab === 'display' }" @click="configTab = 'display'">Display</button>
<template x-for="rid in recommendedTypes" :key="rid"> </div>
<button type="button" class="cb-cb-type cb-cb-type--compact" :class="{ selected: chartType === rid }" @click="selectChartType(rid)"> <div class="cb-cb-card-b cb-cb-config-body">
<span x-text="labelForType(rid)"></span> <div x-show="configTab === 'data'" x-cloak>
</button> <div class="cb-cb-section mb-3">
</template> <div class="small fw-bold mb-2">Recommended charts</div>
</div> <div class="d-flex flex-wrap gap-2" x-show="recommendedTypes.length" x-cloak>
<p class="small text-muted mb-0" x-show="!recommendedTypes.length">Run query once to get chart recommendations based on your columns.</p> <template x-for="rid in recommendedTypes" :key="rid">
<details class="mt-3"> <button type="button" class="cb-cb-type cb-cb-type--compact" :class="{ selected: chartType === rid }" @click="selectChartType(rid)">
<summary class="small fw-semibold">All chart types</summary> <span x-text="labelForType(rid)"></span>
<div class="cb-cb-type-grid mt-2"> </button>
<template x-for="t in chartTypes" :key="t.id"> </template>
<button type="button" class="cb-cb-type" :class="{ selected: chartType === t.id }" @click="selectChartType(t.id)"> </div>
<span class="cb-cb-type-ico" x-text="t.icon"></span> <p class="small text-muted mb-0" x-show="!recommendedTypes.length">Run query once to get chart recommendations based on your columns.</p>
<span x-text="t.label"></span> <details class="mt-2">
</button> <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> </template>
</div> </div>
</details> </div>
<div class="cb-cb-hint mt-3"><span x-text="typeHint()"></span></div>
</div>
</div>
<div class="cb-cb-card mb-3"> <div x-show="configTab === 'display'" x-cloak>
<div class="cb-cb-card-h"><span class="fw-bold">Field mapping</span></div> <div class="cb-cb-section mb-3">
<div class="cb-cb-card-b"> <div class="small fw-bold mb-2">Basic style</div>
<template x-if="!columns.length"> <div class="mb-2">
<p class="text-muted small mb-0">Run query first to load columns and enable mapping.</p> <label class="form-label small">Chart title</label>
</template> <input type="text" class="form-control form-control-sm" x-model="display.title" @input="queueRenderPreview()" placeholder="Optional">
<template x-if="columns.length"> </div>
<div> <div class="mb-2">
<div class="cb-cb-fm-row" x-show="needsXY()"> <label class="form-label small">Number format</label>
<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="display.number_format" @change="queueRenderPreview()">
<select class="form-select form-select-sm" x-model="xField" @change="queueRenderPreview()"> <option value="auto">Auto</option>
<option value=""></option> <option value="inr"> INR</option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template> <option value="usd">$ USD</option>
<option value="percent">%</option>
<option value="compact">K / M / B</option>
</select> </select>
</div> </div>
<div class="cb-cb-fm-row" x-show="needsXY()"> <div class="form-check form-switch mb-1">
<div><span class="fw-semibold small">Y axis</span><div class="text-muted small">Numeric values</div></div> <input class="form-check-input" type="checkbox" id="lg" x-model="display.show_legend" @change="queueRenderPreview()">
<select class="form-select form-select-sm" x-model="yField" @change="queueRenderPreview()"> <label class="form-check-label small" for="lg">Legend</label>
<option value=""></option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div> </div>
<div class="cb-cb-fm-row" x-show="showGroup()"> <div class="form-check form-switch mb-1">
<div><span class="fw-semibold small">Group / series</span><div class="text-muted small">Optional split</div></div> <input class="form-check-input" type="checkbox" id="dl" x-model="display.show_data_labels" @change="queueRenderPreview()">
<select class="form-select form-select-sm" x-model="groupField" @change="queueRenderPreview()"> <label class="form-check-label small" for="dl">Data labels</label>
<option value=""> None </option>
<template x-for="c in columns" :key="c"><option :value="c" x-text="c"></option></template>
</select>
</div> </div>
<div class="cb-cb-fm-row" x-show="chartType === 'bubble'"> <div class="form-check form-switch mb-0">
<div><span class="fw-semibold small">Bubble size (Z)</span></div> <input class="form-check-input" type="checkbox" id="gr" x-model="display.show_grid" @change="queueRenderPreview()">
<select class="form-select form-select-sm" x-model="valueField" @change="queueRenderPreview()"> <label class="form-check-label small" for="gr">Grid lines</label>
<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>
</div> </div>
</template>
</div>
</div>
<div class="cb-cb-card"> <div class="cb-cb-section">
<div class="cb-cb-card-h"><span class="fw-bold">Basic style</span></div> <div class="small fw-bold mb-2">Advanced options</div>
<div class="cb-cb-card-b"> <button type="button" class="cb-cb-advanced-toggle" data-bs-toggle="collapse" data-bs-target="#cbAdvancedStyleInline" aria-expanded="false" aria-controls="cbAdvancedStyleInline">
<div class="mb-2"> <span class="material-symbols-outlined">tune</span>
<label class="form-label small">Chart title</label> <span>Advanced style controls</span>
<input type="text" class="form-control form-control-sm" x-model="display.title" @input="queueRenderPreview()" placeholder="Optional"> </button>
</div> <div id="cbAdvancedStyleInline" class="collapse mt-2">
<div class="mb-2"> <div class="row g-3">
<label class="form-label small">Number format</label> <div class="col-12">
<select class="form-select form-select-sm" x-model="display.number_format" @change="queueRenderPreview()"> <label class="form-label small">Subtitle</label>
<option value="auto">Auto</option> <input type="text" class="form-control form-control-sm" x-model="display.subtitle" @input="queueRenderPreview()">
<option value="inr"> INR</option> </div>
<option value="usd">$ USD</option> <div class="col-6">
<option value="percent">%</option> <label class="form-label small">Decimal places</label>
<option value="compact">K / M / B</option> <select class="form-select form-select-sm" x-model.number="display.decimal_places" @change="queueRenderPreview()">
</select> <option value="0">0</option>
</div> <option value="1">1</option>
<div class="form-check form-switch mb-1"> <option value="2">2</option>
<input class="form-check-input" type="checkbox" id="lg" x-model="display.show_legend" @change="queueRenderPreview()"> </select>
<label class="form-check-label small" for="lg">Legend</label> </div>
</div> <div class="col-6">
<div class="form-check form-switch mb-1"> <label class="form-label small">Colors</label>
<input class="form-check-input" type="checkbox" id="dl" x-model="display.show_data_labels" @change="queueRenderPreview()"> <select class="form-select form-select-sm" x-model="display.color_mode" @change="ensurePaletteColors(); queueRenderPreview();">
<label class="form-check-label small" for="dl">Data labels</label> <option value="preset">Theme</option>
</div> <option value="custom">Custom</option>
<div class="form-check form-switch mb-0"> </select>
<input class="form-check-input" type="checkbox" id="gr" x-model="display.show_grid" @change="queueRenderPreview()"> </div>
<label class="form-check-label small" for="gr">Grid lines</label> </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> </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> <span class="fw-bold">Live preview</span>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<span class="small text-muted" x-text="renderMeta"></span> <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> </div>
<div class="cb-cb-card-b"> <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> <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> <p class="text-danger small mt-2 mb-0" x-show="renderError" x-text="renderError"></p>
</div> </div>
@ -243,116 +351,13 @@ $bootstrapJson = json_encode($bootstrap, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div> </div>
<div class="cb-cb-card mt-3"> <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="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="row g-3 mt-1">
<div class="col-lg-6"> <div class="col-lg-8">
<label class="form-label small">Chart name</label> <label class="form-label small">Chart name</label>
<input type="text" class="form-control" name="name" x-model="chartName" required minlength="2" maxlength="200"> <input type="text" class="form-control" name="name" x-model="chartName" required minlength="2" maxlength="200">
</div> </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"> <div class="col-lg-4">
<label class="form-label small">Auto-refresh (dashboards)</label> <label class="form-label small">Auto-refresh (dashboards)</label>
<select class="form-select form-select-sm" name="refresh_interval" x-model.number="refreshInterval"> <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> </div>
<div class="d-flex justify-content-end mt-3 pt-2 border-top"> <div class="row g-3 mt-1 align-items-end">
<button type="submit" class="btn btn-dark"><?= $mode === 'edit' ? 'Update chart' : 'Save chart' ?></button> <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> </div>
</div> </div>

View File

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

View File

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

View File

@ -17,27 +17,31 @@ $themeClass = match ($theme) {
?> ?>
<div class="cb-dash-shell <?= esc($themeClass) ?>" id="cbDashShell"> <div class="cb-dash-shell <?= esc($themeClass) ?>" id="cbDashShell">
<div class="cb-dash-toolbar"> <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> <h1><?= esc($dashboard['name']) ?></h1>
<div class="cb-dash-meta"><?= esc($dashboard['description'] ?? '') ?></div> <div class="cb-dash-meta"><?= esc($dashboard['description'] ?? '') ?></div>
</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="cb-dash-toolbar-actions">
<div class="d-flex flex-wrap align-items-center gap-2"> <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-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-primary d-none" id="cbBtnSaveLayout">Save layout</button>
<button type="button" class="btn btn-sm btn-dark" id="cbBtnToggleEdit"> <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 class="material-symbols-outlined cb-btn-icon">edit_square</span>
<span id="cbBtnToggleEditLabel">Edit layout</span> <span id="cbBtnToggleEditLabel">Edit layout</span>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnAddWidget" data-bs-toggle="modal" data-bs-target="#modalAddWidget" hidden> <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 <span class="material-symbols-outlined cb-btn-icon">add_box</span>
</button> <span>Add widget</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnFs" title="Fullscreen"> </button>
<span class="material-symbols-outlined" style="font-size:18px;">fullscreen</span> </div>
</button> <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" data-bs-toggle="modal" data-bs-target="#modalShare" data-share-type="dashboard" data-resource-id="<?= $did ?>">Share</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="cbBtnFs" title="Fullscreen">
<a href="<?= base_url('dashboard/settings/' . $did) ?>" class="btn btn-sm btn-outline-secondary">Settings</a> <span class="material-symbols-outlined cb-btn-icon">fullscreen</span>
<a href="<?= base_url('dashboard') ?>" class="btn btn-sm btn-light border">All boards</a> </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>
</div> </div>

View File

@ -165,6 +165,128 @@
.cb-cb-preview-card { .cb-cb-preview-card {
position: sticky; position: sticky;
top: calc(var(--topbar-height) + 14px); 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 { .cb-cb-hint {
@ -259,4 +381,34 @@
position: static; position: static;
top: auto; 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 { .cb-dash-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-start;
gap: 10px; justify-content: space-between;
padding: 14px 18px; gap: 12px 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 16px; border-radius: 14px;
margin-bottom: 18px; 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 { .cb-dash-toolbar h1 {
font-size: 1.15rem; font-size: 1.08rem;
font-weight: 800; font-weight: 800;
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin: 0; margin: 0;
line-height: 1.25;
} }
.cb-dash-toolbar .cb-dash-meta { .cb-dash-toolbar .cb-dash-meta {
font-size: 12px; font-size: 12px;
color: #64748b; 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 { .cb-dash-shell.cb-theme-dark .cb-dash-toolbar .cb-dash-meta {
color: #94a3b8; 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 { .cb-dash-grid-wrap {
min-height: 400px; 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 */ /* Stretch widgets to the full GridStack cell so paired cards align visually */
@ -233,7 +293,8 @@
} }
.cb-dash-widget-card { .cb-dash-widget-card {
height: 100%; height: calc(100% - 12px);
margin: 6px;
background: #fff; background: #fff;
border: 1px solid #e8ecf1; border: 1px solid #e8ecf1;
border-radius: 14px; border-radius: 14px;
@ -254,6 +315,13 @@
color: #0f172a; 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 { .cb-dash-shell.cb-theme-dark .cb-dash-widget-head {
border-bottom-color: rgba(148, 163, 184, 0.15); border-bottom-color: rgba(148, 163, 184, 0.15);
color: #f1f5f9; color: #f1f5f9;
@ -282,13 +350,23 @@
width: 100%; width: 100%;
} }
.cb-dash-widget-edit,
.cb-dash-widget-remove { .cb-dash-widget-remove {
display: inline-flex;
align-items: center;
justify-content: center;
border: none; border: none;
background: transparent; background: transparent;
color: #94a3b8; color: #94a3b8;
padding: 2px; padding: 2px;
line-height: 1; line-height: 1;
border-radius: 6px; border-radius: 6px;
text-decoration: none;
}
.cb-dash-widget-edit:hover {
background: #eef2ff;
color: #4f46e5;
} }
.cb-dash-widget-remove:hover { .cb-dash-widget-remove:hover {
@ -296,14 +374,6 @@
color: #dc2626; color: #dc2626;
} }
.cb-dash-edit-hint {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6366f1;
}
.cb-dash-prose { .cb-dash-prose {
font-size: 14px; font-size: 14px;
line-height: 1.65; line-height: 1.65;
@ -400,3 +470,50 @@ body.cb-dash-fs .cb-dash-shell {
.grid-stack.cb-dash-editing .grid-stack-item { .grid-stack.cb-dash-editing .grid-stack-item {
cursor: grab; 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;
}
}