diff --git a/src/components/sections/DashboardWorkspace.jsx b/src/components/sections/DashboardWorkspace.jsx index 40964de..6562aaf 100644 --- a/src/components/sections/DashboardWorkspace.jsx +++ b/src/components/sections/DashboardWorkspace.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { LineChart, ArrowUp, ChevronLeft, Sparkles, Check, Loader2, Brain, PencilRuler, ClipboardCheck } from 'lucide-react' +import { LineChart, ArrowUp, ChevronLeft, Sparkles, Check, Loader2, Brain, PencilRuler, ClipboardCheck, TrendingUp, Users, PieChart, Layers } from 'lucide-react' import DashboardArtifact from '@/components/DashboardArtifact' import ShareMenu from './ShareMenu' import { savedDashboardsAPI } from '@/lib/api/client' @@ -9,6 +9,15 @@ import styles from './DashboardWorkspace.module.css' // Icon per agent step phase, so the live trace reads at a glance. const STEP_ICON = { grounding: Brain, planning: PencilRuler, validating: ClipboardCheck, done: Check } +// Starting points shown on a brand-new, empty dashboard — concrete enough to click +// and send immediately, so the first screen a user sees isn't just an empty prompt. +const EXAMPLE_PROMPTS = [ + { icon: TrendingUp, title: 'Revenue by month', prompt: 'Show revenue by month for the last 12 months, with a trend line and month-over-month change.' }, + { icon: Users, title: 'Top customers', prompt: 'Show top customers by total spend, with their order count and average order value.' }, + { icon: PieChart, title: 'Order breakdown', prompt: 'Show order status breakdown (completed, pending, cancelled) as a share of total orders.' }, + { icon: Layers, title: 'Business overview', prompt: 'Build a dashboard with the most important KPIs and a couple of charts summarizing overall business health.' }, +] + // Focused, chrome-less builder. Left = the agent (generate / refine); main = the // live dashboard canvas. The DeepSQL logo and breadcrumb return to the gallery. export default function DashboardWorkspace({ connectionId, dashboard, onClose }) { @@ -56,7 +65,11 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) } let cfg = {} try { cfg = typeof dashboard.dashboardConfig === 'string' ? JSON.parse(dashboard.dashboardConfig || '{}') : (dashboard.dashboardConfig || {}) } catch { cfg = {} } - setConfig({ ...cfg, updatedAt: dashboard.updatedAt || new Date().toISOString() }) + // A saved row can carry a chat-only reply object (from an in-flight chat turn + // that was never a real build) instead of an artifact — rendering that as-is + // would silently show the pristine empty canvas with no explanation, right next + // to chat history that says "Done — built". Treat it as no build yet instead. + setConfig(cfg.html ? { ...cfg, updatedAt: dashboard.updatedAt || new Date().toISOString() } : null) chatSyncedRef.current = false // Restore the persisted per-dashboard chat thread if there is one, so the // build/edit conversation survives a refresh; otherwise open with a greeting. @@ -116,8 +129,8 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) } }, [connectionId]) - function submit() { - const prompt = input.trim() + function submit(directPrompt) { + const prompt = (directPrompt ?? input).trim() if (!prompt || thinking) return setInput('') setMessages((m) => [...m, { role: 'user', text: prompt }]) @@ -243,7 +256,7 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!thinking) submit() } }} autoFocus /> - + @@ -252,10 +265,32 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose }) console.warn('Dashboard artifact error:', msg)} /> ) : (
- -

Build a dashboard

-

Describe what you want on the left and the DeepSQL agent builds it here — read-only, grounded on your data.

- +
+ +

{isNew ? 'Build a dashboard' : 'Nothing built here yet'}

+

+ {isNew + ? 'Describe what you want on the left and the DeepSQL agent builds it here — read-only, grounded on your data.' + : 'This dashboard doesn’t have a build yet — the chat on the left may just be planning so far. Ask for a chart to get started.'} +

+ +
+ {EXAMPLE_PROMPTS.map(({ icon: Icon, title, prompt }) => ( + + ))} +
+ + +
)} diff --git a/src/components/sections/DashboardWorkspace.module.css b/src/components/sections/DashboardWorkspace.module.css index 2b56a6e..b026b25 100644 --- a/src/components/sections/DashboardWorkspace.module.css +++ b/src/components/sections/DashboardWorkspace.module.css @@ -448,12 +448,22 @@ .newCanvas { height: 100%; display: flex; - flex-direction: column; align-items: center; justify-content: center; text-align: center; + padding: 32px 24px; + background: + radial-gradient(ellipse 700px 420px at 50% 0%, rgba(83, 74, 183, 0.05), transparent 70%), + #fff; +} + +.newCanvasInner { + display: flex; + flex-direction: column; + align-items: center; gap: 10px; - padding: 24px; + width: 100%; + max-width: 640px; } .newIcon { @@ -465,11 +475,79 @@ align-items: center; justify-content: center; margin-bottom: 4px; + box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08); } -.newTitle { font-size: 18px; font-weight: 600; letter-spacing: -0.012em; color: #111; margin: 0; } +.newTitle { font-size: 19px; font-weight: 650; letter-spacing: -0.014em; color: #111; margin: 0; } .newSub { font-size: 13px; color: #777; max-width: 380px; line-height: 1.6; margin: 0; } +.exampleGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + width: 100%; + margin: 18px 0 8px; +} + +@media (max-width: 620px) { + .exampleGrid { grid-template-columns: 1fr; } +} + +.exampleCard { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + text-align: left; + padding: 14px 15px; + border: 1px solid #ececec; + border-radius: 13px; + background: #fff; + cursor: pointer; + transition: border-color 180ms ease-out, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms ease-out; +} +.exampleCard:hover { + border-color: #cfcaf0; + transform: translateY(-2px); + box-shadow: 0 8px 18px -10px rgba(83, 74, 183, 0.25), 0 1px 3px rgba(0, 0, 0, 0.04); +} +.exampleCard:active { transform: translateY(-1px) scale(0.99); } +.exampleCard:disabled { opacity: 0.5; cursor: default; transform: none; } + +@media (prefers-reduced-motion: reduce) { + .exampleCard { transition: border-color 180ms ease-out, box-shadow 180ms ease-out; } + .exampleCard:hover, .exampleCard:active { transform: none; } +} + +.exampleIcon { + width: 28px; + height: 28px; + border-radius: 8px; + background: #EEEDFE; + color: #534AB7; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.exampleTitle { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.005em; + color: #111; +} + +.examplePrompt { + font-size: 12px; + line-height: 1.5; + color: #8a8a8a; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + .backLink { display: inline-flex; align-items: center; diff --git a/src/components/sections/DashboardsHome.jsx b/src/components/sections/DashboardsHome.jsx index 65dc3b4..1f8124d 100644 --- a/src/components/sections/DashboardsHome.jsx +++ b/src/components/sections/DashboardsHome.jsx @@ -1,15 +1,10 @@ -import { useState, useEffect, useCallback } from 'react' -import { Plus, Sparkles, Clock, RefreshCw, Trash2, Loader2 } from 'lucide-react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { Plus, Sparkles, Clock, RefreshCw, Trash2, Loader2, LayoutDashboard, AlertTriangle } from 'lucide-react' import { savedDashboardsAPI } from '@/lib/api/client' import styles from './DashboardsHome.module.css' -function parseConfig(d) { - try { return typeof d.dashboardConfig === 'string' ? JSON.parse(d.dashboardConfig || '{}') : (d.dashboardConfig || {}) } - catch { return {} } -} // A dashboard is "live" once it's published to the web (has a public link). function statusOf(d) { return d.isPublic ? 'live' : 'draft' } -function chartCount(d) { return (parseConfig(d).charts || []).length } function relTime(iso) { if (!iso) return '' @@ -22,17 +17,35 @@ function relTime(iso) { return `${Math.round(h / 24)}d ago` } -function Thumb({ charts }) { - if (charts <= 0) { - return - } - const heights = [40, 62, 80, 55, 34] +// A tiny hash of the dashboard's own id, so each card's thumbnail bars look +// distinct and stable across reloads rather than identical or random. +function seedFrom(id) { + let h = 0 + for (let i = 0; i < (id || '').length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0 + return h +} + +// Dashboard content isn't parseable from dashboardConfig (it's an opaque HTML +// artifact, not spec data) — so the thumbnail is a stylized mini-preview, not a +// real render. Deterministic per-id bar heights keep the gallery from looking +// like every card is the exact same placeholder. +function Thumb({ id }) { + const seed = seedFrom(id) + // Unsigned shift + byte mask — a signed >> with a big shift/modulo combo can + // yield negative numbers in JS, which collapse a bar to 0 height. + const heights = [0, 1, 2, 3, 4].map((i) => 30 + (((seed >>> (i * 4)) & 0xff) % 60)) return ( -
- {heights.map((h, i) => )} +
+
+ + + +
+
+ {heights.map((h, i) => ( + + ))} +
) } @@ -42,17 +55,33 @@ export default function DashboardsHome({ connectionId, onOpen }) { const [loading, setLoading] = useState(false) const [filter, setFilter] = useState('all') const [deletingId, setDeletingId] = useState(null) + // Id of the card showing its inline "delete this?" popover (native window.confirm + // reads as out-of-place browser chrome next to the rest of this redesigned UI, and + // some embedded/webview hosts suppress it outright, silently no-opping the delete). + const [confirmId, setConfirmId] = useState(null) + const [deleteError, setDeleteError] = useState(null) + const confirmRef = useRef(null) + + useEffect(() => { + if (!confirmId) return + const onDocClick = (e) => { if (confirmRef.current && !confirmRef.current.contains(e.target)) setConfirmId(null) } + const onKey = (e) => { if (e.key === 'Escape') setConfirmId(null) } + document.addEventListener('mousedown', onDocClick) + document.addEventListener('keydown', onKey) + return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey) } + }, [confirmId]) const remove = useCallback(async (d, e) => { e?.stopPropagation() if (deletingId) return - if (!window.confirm(`Delete “${d.name || 'this dashboard'}”? This can’t be undone.`)) return + setConfirmId(null) + setDeleteError(null) setDeletingId(d.id) try { await savedDashboardsAPI.deleteDashboard(d.id) setDashboards((list) => list.filter((x) => x.id !== d.id)) } catch (err) { - window.alert(`Couldn’t delete: ${err?.response?.data?.message || err?.message || 'error'}`) + setDeleteError(err?.response?.data?.message || err?.message || 'Couldn’t delete this dashboard.') } finally { setDeletingId(null) } @@ -77,7 +106,12 @@ export default function DashboardsHome({ connectionId, onOpen }) { return (
-

Dashboards

+
+

Dashboards

+ {dashboards.length > 0 && ( +

{dashboards.length} dashboard{dashboards.length === 1 ? '' : 's'} for this connection

+ )} +
+ {!loading && dashboards.length === 0 ? ( +
+ +

No dashboards yet

+

Describe what you want to see and the DeepSQL agent will build it — grounded on your schema, verified against your data.

+ +
+ ) : (
{shown.map((d) => (
onOpen(d)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(d) } }} > - -
+
+ + {confirmId === d.id && ( +
e.stopPropagation()}> +

Delete “{d.name || 'this dashboard'}”? This can’t be undone.

+
+ + +
+
+ )} +
+
{d.name || 'Untitled'} @@ -134,9 +189,18 @@ export default function DashboardsHome({ connectionId, onOpen }) { “Build a revenue dashboard for last quarter”
+ )} + + {!loading && dashboards.length > 0 && shown.length === 0 && ( +
No {filter} dashboards — try a different filter.
+ )} - {!loading && shown.length === 0 && ( -
No dashboards here yet — create one, or ask the agent to build it.
+ {deleteError && ( +
+ + {deleteError} + +
)}
) diff --git a/src/components/sections/DashboardsHome.module.css b/src/components/sections/DashboardsHome.module.css index 8b621b5..2f55bfb 100644 --- a/src/components/sections/DashboardsHome.module.css +++ b/src/components/sections/DashboardsHome.module.css @@ -12,6 +12,8 @@ gap: 12px; } +.headerText { display: flex; flex-direction: column; gap: 3px; } + .title { font-size: 22px; font-weight: 650; @@ -20,6 +22,12 @@ margin: 0; } +.subtitle { + font-size: 13px; + color: #8a8a8a; + margin: 0; +} + .actions { display: flex; align-items: center; gap: 8px; } .ghost { @@ -116,11 +124,14 @@ .card:active { transform: none; } } -.cardDelete { +.deleteWrap { position: absolute; top: 8px; right: 8px; - z-index: 2; + z-index: 3; +} + +.cardDelete { display: inline-flex; align-items: center; justify-content: center; @@ -128,9 +139,10 @@ height: 28px; border: 1px solid #ececec; border-radius: 8px; - background: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); color: #6b7280; cursor: pointer; opacity: 0; @@ -148,13 +160,101 @@ .cardDelete:active:not(:disabled) { transform: none; } } +.confirmPopover { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 220px; + padding: 12px; + border: 1px solid #ececec; + border-radius: 12px; + background: #fff; + box-shadow: 0 12px 28px -10px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.06); + animation: dsqlpopin 140ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +@keyframes dsqlpopin { from { opacity: 0; transform: translateY(-4px) scale(0.97); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { .confirmPopover { animation: none; } } + +.confirmText { margin: 0 0 10px; font-size: 12.5px; line-height: 1.5; color: #333; } + +.confirmActions { display: flex; justify-content: flex-end; gap: 6px; } + +.confirmCancel, .confirmDelete { + font-size: 12px; + font-weight: 500; + border-radius: 7px; + padding: 6px 11px; + cursor: pointer; + transition: background 120ms ease-out, border-color 120ms ease-out; +} +.confirmCancel { border: 1px solid #e2e2e2; background: #fff; color: #444; } +.confirmCancel:hover { background: #f5f5f5; } +.confirmDelete { border: 1px solid #dc2626; background: #dc2626; color: #fff; } +.confirmDelete:hover { background: #b91c1c; border-color: #b91c1c; } + +.errorToast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + max-width: min(420px, calc(100vw - 48px)); + padding: 11px 14px; + border: 1px solid #f0c8c8; + border-radius: 11px; + background: #fef2f2; + color: #991b1b; + font-size: 13px; + box-shadow: 0 12px 28px -10px rgba(0, 0, 0, 0.2), 0 2px 8px rgba(0, 0, 0, 0.06); + z-index: 20; + animation: dsqltoastin 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +@keyframes dsqltoastin { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } } +@media (prefers-reduced-motion: reduce) { .errorToast { animation: none; } } + +.errorDismiss { + margin-left: 4px; + border: none; + background: none; + color: #991b1b; + font-size: 16px; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} +.errorDismiss:hover { opacity: 1; } + .thumb { - height: 96px; - background: #fafafa; + height: 108px; + background: linear-gradient(180deg, #fbfaff 0%, #fafafa 100%); border-bottom: 1px solid #f0f0f0; padding: 12px; display: flex; - align-items: flex-end; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + +.thumbMock { + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +.thumbMockKpis { + display: flex; + gap: 6px; +} + +.thumbMockKpi { + flex: 1; + height: 16px; + border-radius: 5px; + background: #fff; + border: 1px solid #ececec; } .thumbBars { @@ -162,9 +262,16 @@ align-items: flex-end; gap: 6px; width: 100%; - height: 100%; + flex: 1; + min-height: 0; } -.thumbBars span { flex: 1; border-radius: 3px; } +.thumbBar, +.thumbBarAccent { + flex: 1; + border-radius: 3px 3px 0 0; +} +.thumbBar { background: #d9d5f2; } +.thumbBarAccent { background: #534AB7; } .cardBody { padding: 12px 14px; } @@ -242,3 +349,33 @@ .ctaSub { font-size: 12px; color: #777; line-height: 1.5; } .emptyNote { margin-top: 20px; font-size: 13px; color: #999; } + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; + padding: 64px 24px; + border: 1px dashed #e2e2e2; + border-radius: 16px; + background: + radial-gradient(ellipse 500px 260px at 50% 0%, rgba(83, 74, 183, 0.04), transparent 70%), + #fff; +} + +.emptyIcon { + width: 52px; + height: 52px; + border-radius: 14px; + background: #EEEDFE; + color: #534AB7; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; + box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08); +} + +.emptyTitle { font-size: 17px; font-weight: 650; letter-spacing: -0.012em; color: #111; margin: 0; } +.emptySub { font-size: 13px; color: #777; max-width: 380px; line-height: 1.6; margin: 0 0 6px; }