Skip to content

Commit 53795d4

Browse files
committed
feat: enhance UI components and add command palette functionality
- Refactor AnalysisResultDisplay component for improved chart options and layout. - Introduce CommandPalette component for keyboard navigation and command execution. - Update AppLayout to integrate CommandPalette and handle keyboard shortcuts. - Enhance AppSidebar to show loading states and improve connection feedback. - Modify AppTopBar to trigger CommandPalette. - Implement caching mechanism in dbService for account and database data. - Optimize DataExplorerPageWrapper and QueryGeneratorPage for better session handling and account switching.
1 parent fb15a31 commit 53795d4

8 files changed

Lines changed: 783 additions & 94 deletions

File tree

Lines changed: 150 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,170 @@
1-
21
import React from 'react';
3-
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement, PolarAreaController, RadarController, DoughnutController, PieController, BarController, LineController, ScatterController, BubbleController } from 'chart.js';
2+
import {
3+
Chart as ChartJS,
4+
CategoryScale, LinearScale, BarElement,
5+
Title, Tooltip, Legend, ArcElement,
6+
PointElement, LineElement,
7+
PolarAreaController, RadarController,
8+
DoughnutController, PieController,
9+
BarController, LineController,
10+
ScatterController, BubbleController,
11+
} from 'chart.js';
412
import { Chart } from 'react-chartjs-2';
513
import { AnalysisResult } from '../types';
6-
import { AiSparkleIcon } from './icons/material-icons-imports';
714
import { useTheme } from '../contexts/ThemeContext';
815

9-
// Register all necessary Chart.js components
1016
ChartJS.register(
11-
CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ArcElement, PointElement, LineElement,
12-
PolarAreaController, RadarController, DoughnutController, PieController, BarController, LineController,
13-
ScatterController, BubbleController
17+
CategoryScale, LinearScale, BarElement,
18+
Title, Tooltip, Legend, ArcElement,
19+
PointElement, LineElement,
20+
PolarAreaController, RadarController,
21+
DoughnutController, PieController,
22+
BarController, LineController,
23+
ScatterController, BubbleController,
1424
);
1525

1626
interface AnalysisResultDisplayProps {
17-
result: AnalysisResult;
27+
result: AnalysisResult;
1828
}
1929

30+
const SparkleIcon = () => (
31+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round">
32+
<path d="M8 1v3M8 12v3M1 8h3M12 8h3M3.05 3.05l2.12 2.12M10.83 10.83l2.12 2.12M3.05 12.95l2.12-2.12M10.83 5.17l2.12-2.12" />
33+
</svg>
34+
);
35+
36+
const CHART_TYPE_LABELS: Record<string, string> = {
37+
bar: 'Bar chart',
38+
line: 'Line chart',
39+
pie: 'Pie chart',
40+
doughnut: 'Doughnut chart',
41+
polarArea: 'Polar area',
42+
radar: 'Radar chart',
43+
scatter: 'Scatter plot',
44+
bubble: 'Bubble chart',
45+
};
46+
2047
const AnalysisResultDisplay: React.FC<AnalysisResultDisplayProps> = ({ result }) => {
21-
const { theme } = useTheme();
48+
const { theme } = useTheme();
49+
50+
const themedChartOptions = React.useMemo(() => {
51+
const isDark = theme === 'dark';
52+
const textColor = isDark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.45)';
53+
const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)';
54+
const base = result.chartOptions || {};
2255

23-
// Dynamically adjust chart options for dark/light theme
24-
const themedChartOptions = React.useMemo(() => {
25-
const isDark = theme === 'dark';
26-
const textColor = isDark ? '#cbd5e1' : '#475569'; // slate-300 : slate-600
27-
const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
56+
return {
57+
...base,
58+
responsive: true,
59+
maintainAspectRatio: false,
60+
plugins: {
61+
...base.plugins,
62+
legend: {
63+
...base.plugins?.legend,
64+
labels: {
65+
...base.plugins?.legend?.labels,
66+
color: textColor,
67+
font: { family: 'Geist, sans-serif', size: 11 },
68+
boxWidth: 10,
69+
padding: 14,
70+
},
71+
},
72+
title: { ...base.plugins?.title, color: textColor },
73+
tooltip: {
74+
...base.plugins?.tooltip,
75+
backgroundColor: isDark ? 'rgba(30,30,36,0.95)' : 'rgba(255,255,255,0.98)',
76+
titleColor: isDark ? '#e2e8f0' : '#1e293b',
77+
bodyColor: isDark ? '#94a3b8' : '#475569',
78+
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)',
79+
borderWidth: 1,
80+
cornerRadius: 8,
81+
padding: 10,
82+
titleFont: { family: 'Geist, sans-serif', size: 12, weight: '600' },
83+
bodyFont: { family: 'Geist Mono, monospace', size: 11 },
84+
},
85+
},
86+
scales: {
87+
x: {
88+
...base.scales?.x,
89+
ticks: { ...base.scales?.x?.ticks, color: textColor, font: { family: 'Geist Mono, monospace', size: 10 } },
90+
grid: { ...base.scales?.x?.grid, color: gridColor },
91+
border: { color: gridColor },
92+
title: { ...base.scales?.x?.title, color: textColor },
93+
},
94+
y: {
95+
...base.scales?.y,
96+
ticks: { ...base.scales?.y?.ticks, color: textColor, font: { family: 'Geist Mono, monospace', size: 10 } },
97+
grid: { ...base.scales?.y?.grid, color: gridColor },
98+
border: { color: gridColor },
99+
title: { ...base.scales?.y?.title, color: textColor },
100+
},
101+
},
102+
};
103+
}, [result.chartOptions, theme]);
28104

29-
// Deep merge our theme options into the AI-provided options
30-
const baseOptions = result.chartOptions || {};
31-
32-
const themeOverrides = {
33-
plugins: {
34-
legend: {
35-
labels: { color: textColor }
36-
},
37-
title: {
38-
color: textColor
39-
}
40-
},
41-
scales: {
42-
x: {
43-
...baseOptions.scales?.x,
44-
ticks: { ...baseOptions.scales?.x?.ticks, color: textColor },
45-
grid: { ...baseOptions.scales?.x?.grid, color: gridColor },
46-
title: { ...baseOptions.scales?.x?.title, color: textColor }
47-
},
48-
y: {
49-
...baseOptions.scales?.y,
50-
ticks: { ...baseOptions.scales?.y?.ticks, color: textColor },
51-
grid: { ...baseOptions.scales?.y?.grid, color: gridColor },
52-
title: { ...baseOptions.scales?.y?.title, color: textColor }
53-
}
54-
}
55-
};
56-
57-
// A simple deep merge
58-
return {
59-
...baseOptions,
60-
...themeOverrides,
61-
plugins: { ...baseOptions.plugins, ...themeOverrides.plugins },
62-
scales: { ...baseOptions.scales, ...themeOverrides.scales }
63-
};
105+
const chartLabel = CHART_TYPE_LABELS[result.chartType] ?? result.chartType;
64106

65-
}, [result.chartOptions, theme]);
107+
return (
108+
<div
109+
className="qa-card"
110+
style={{ padding: 0, overflow: 'hidden', animation: 'fadeIn 0.25s' }}
111+
>
112+
{/* Header — matches QueryResult card header */}
113+
<div style={{
114+
display: 'flex', alignItems: 'center', gap: 10,
115+
padding: '9px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0,
116+
}}>
117+
<span style={{
118+
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
119+
background: 'var(--accent-soft)',
120+
display: 'flex', alignItems: 'center', justifyContent: 'center',
121+
}}>
122+
<SparkleIcon />
123+
</span>
124+
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg)', fontFamily: 'var(--font-body)' }}>
125+
AI Analysis
126+
</span>
127+
<span className="qa-chip accent" style={{ textTransform: 'capitalize', marginLeft: 2 }}>
128+
{chartLabel}
129+
</span>
130+
</div>
131+
132+
{/* Body — insight + chart side by side */}
133+
<div style={{ display: 'flex', minHeight: 320 }}>
134+
{/* Insight panel */}
135+
<div style={{
136+
width: 220, flexShrink: 0,
137+
padding: '16px 18px',
138+
borderRight: '1px solid var(--border)',
139+
display: 'flex', flexDirection: 'column', gap: 10,
140+
background: 'var(--soft)',
141+
}}>
142+
<div style={{
143+
fontSize: 10.5, fontWeight: 500,
144+
textTransform: 'uppercase', letterSpacing: '0.08em',
145+
color: 'var(--muted)', fontFamily: 'var(--font-body)',
146+
}}>
147+
Insight
148+
</div>
149+
<p style={{
150+
fontSize: 13, color: 'var(--fg)', lineHeight: 1.65,
151+
fontFamily: 'var(--font-body)', margin: 0,
152+
}}>
153+
{result.insight}
154+
</p>
155+
</div>
66156

67-
return (
68-
<div className="space-y-4 animate-fade-in">
69-
<h3 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">AI Analysis</h3>
70-
<div className="bg-white dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg p-6 flex flex-col md:flex-row gap-6">
71-
{/* Insight Text */}
72-
<div className="md:w-1/3 space-y-4">
73-
<div className="flex items-center gap-3">
74-
<AiSparkleIcon className="w-7 h-7 text-blue-500 flex-shrink-0" />
75-
<h4 className="text-lg font-bold text-slate-800 dark:text-slate-200">Insight</h4>
76-
</div>
77-
<p className="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">
78-
{result.insight}
79-
</p>
80-
</div>
81-
{/* Chart */}
82-
<div className="md:w-2/3 min-h-[300px]">
83-
<Chart
84-
type={result.chartType}
85-
data={result.chartData}
86-
options={themedChartOptions}
87-
/>
88-
</div>
89-
</div>
157+
{/* Chart panel */}
158+
<div style={{ flex: 1, padding: '20px 24px', position: 'relative', minWidth: 0 }}>
159+
<Chart
160+
type={result.chartType}
161+
data={result.chartData}
162+
options={themedChartOptions}
163+
/>
90164
</div>
91-
);
165+
</div>
166+
</div>
167+
);
92168
};
93169

94170
export default AnalysisResultDisplay;

frontend/components/AppLayout.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import React from 'react';
1+
import React, { useState, useEffect } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import { useTheme } from '../contexts/ThemeContext';
24
import AppSidebar from './AppSidebar';
35
import AppTopBar from './AppTopBar';
6+
import CommandPalette from './CommandPalette';
47
import { CollectionSummary, DbInfo, CosmosDBAccount } from '../types';
58

69
interface AppLayoutProps {
@@ -34,6 +37,46 @@ const AppLayout: React.FC<AppLayoutProps> = ({
3437
availableAccounts,
3538
onSwitchAccount,
3639
}) => {
40+
const navigate = useNavigate();
41+
const { toggleTheme } = useTheme();
42+
const [paletteOpen, setPaletteOpen] = useState(false);
43+
44+
const explorerHref = accountId && databaseName
45+
? `/data-explorer/${encodeURIComponent(accountId)}/${encodeURIComponent(databaseName)}`
46+
: null;
47+
48+
useEffect(() => {
49+
const handle = (e: KeyboardEvent) => {
50+
const meta = e.metaKey || e.ctrlKey;
51+
if (!meta) return;
52+
53+
if (e.key === 'k') {
54+
e.preventDefault();
55+
setPaletteOpen(v => !v);
56+
return;
57+
}
58+
59+
if (e.shiftKey) {
60+
switch (e.key) {
61+
case '1': e.preventDefault(); setPaletteOpen(false); navigate('/query-generator'); return;
62+
case '2': e.preventDefault(); setPaletteOpen(false); if (explorerHref) navigate(explorerHref); return;
63+
case '3': e.preventDefault(); setPaletteOpen(false); navigate('/analytics'); return;
64+
case '4': e.preventDefault(); setPaletteOpen(false); navigate('/audit'); return;
65+
case 'S': case 's': e.preventDefault(); setPaletteOpen(false); navigate('/query-generator?panel=saved'); return;
66+
}
67+
} else {
68+
if (e.key === 'n' || e.key === 'N') {
69+
e.preventDefault();
70+
setPaletteOpen(false);
71+
onNewQuery?.();
72+
return;
73+
}
74+
}
75+
};
76+
window.addEventListener('keydown', handle);
77+
return () => window.removeEventListener('keydown', handle);
78+
}, [navigate, explorerHref, onNewQuery, toggleTheme]);
79+
3780
return (
3881
<div className="qp-app">
3982
<AppSidebar
@@ -54,11 +97,25 @@ const AppLayout: React.FC<AppLayoutProps> = ({
5497
databaseName={databaseName}
5598
collectionName={collectionName}
5699
onNewQuery={onNewQuery}
100+
onOpenPalette={() => setPaletteOpen(true)}
57101
/>
58102
<main className="qp-content">
59103
{children}
60104
</main>
61105
</div>
106+
<CommandPalette
107+
open={paletteOpen}
108+
onClose={() => setPaletteOpen(false)}
109+
accountId={accountId}
110+
databaseName={databaseName}
111+
collections={collections}
112+
availableDbs={availableDbs}
113+
availableAccounts={availableAccounts}
114+
onSwitchDatabase={onSwitchDatabase}
115+
onSwitchAccount={onSwitchAccount}
116+
onCollectionSelect={onCollectionSelect}
117+
onNewQuery={onNewQuery}
118+
/>
62119
</div>
63120
);
64121
};

frontend/components/AppSidebar.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,21 +149,20 @@ const AppSidebar: React.FC<AppSidebarProps> = ({
149149
</Link>
150150

151151
{/* Connection chip */}
152-
{accountName && (
152+
{(accountName || isSwitching) && (
153153
<div ref={chipRef} style={{ position: 'relative' }}>
154-
<style>{`@keyframes qp-spin { to { transform: rotate(360deg); } }`}</style>
154+
<style>{`
155+
@keyframes qp-spin { to { transform: rotate(360deg); } }
156+
@keyframes qp-pulse { 0%,100% { opacity: 0.45; } 50% { opacity: 0.9; } }
157+
`}</style>
155158
{isSwitching ? (
156159
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '5px 9px', background: 'var(--soft)', borderRadius: 7 }}>
157160
<div style={{ width: 14, height: 14, borderRadius: '50%', border: '2px solid var(--border)', borderTopColor: 'var(--accent)', animation: 'qp-spin 0.7s linear infinite', flexShrink: 0 }} />
158161
<div style={{ minWidth: 0, flex: 1 }}>
159162
<div style={{ fontSize: 11.5, fontWeight: 500, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
160163
Connecting…
161164
</div>
162-
{databaseName && (
163-
<div style={{ fontSize: 10.5, color: 'var(--muted)', fontFamily: 'var(--font-mono)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.5 }}>
164-
{databaseName}
165-
</div>
166-
)}
165+
<div style={{ height: 9, marginTop: 3, borderRadius: 4, background: 'var(--border)', animation: 'qp-pulse 1.4s ease-in-out infinite', width: '70%' }} />
167166
</div>
168167
</div>
169168
) : (() => {
@@ -357,8 +356,23 @@ const AppSidebar: React.FC<AppSidebarProps> = ({
357356
})}
358357
</div>
359358

360-
{/* Collections */}
361-
{collections && collections.length > 0 && (
359+
{/* Collections — skeleton while switching, real list when loaded */}
360+
{isSwitching && (
361+
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', marginTop: 12 }}>
362+
<div style={{ padding: '0 8px 6px 8px', fontSize: 10.5, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--muted)' }}>
363+
Collections
364+
</div>
365+
<div style={{ padding: '0 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
366+
{[60, 80, 50].map((w, i) => (
367+
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '5px 8px' }}>
368+
<div style={{ width: 11, height: 11, borderRadius: 3, background: 'var(--border)', flexShrink: 0, animation: 'qp-pulse 1.4s ease-in-out infinite' }} />
369+
<div style={{ height: 10, borderRadius: 4, background: 'var(--border)', width: `${w}%`, animation: 'qp-pulse 1.4s ease-in-out infinite', animationDelay: `${i * 0.15}s` }} />
370+
</div>
371+
))}
372+
</div>
373+
</div>
374+
)}
375+
{!isSwitching && collections && collections.length > 0 && (
362376
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', marginTop: 12 }}>
363377
<div style={{
364378
display: 'flex', alignItems: 'center', justifyContent: 'space-between',

0 commit comments

Comments
 (0)