Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 44 additions & 9 deletions src/components/sections/DashboardWorkspace.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 }])
Expand Down Expand Up @@ -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
/>
<button className={styles.sendBtn} onClick={submit} disabled={thinking || !input.trim()} aria-label="Send"><ArrowUp size={16} /></button>
<button className={styles.sendBtn} onClick={() => submit()} disabled={thinking || !input.trim()} aria-label="Send"><ArrowUp size={16} /></button>
</div>
</aside>

Expand All @@ -252,10 +265,32 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
<DashboardArtifact connectionId={connectionId} html={config.html} onError={(msg) => console.warn('Dashboard artifact error:', msg)} />
) : (
<div className={styles.newCanvas}>
<span className={styles.newIcon}><Sparkles size={24} color="#534AB7" /></span>
<h2 className={styles.newTitle}>Build a dashboard</h2>
<p className={styles.newSub}>Describe what you want on the left and the DeepSQL agent builds it here — read-only, grounded on your data.</p>
<button className={styles.backLink} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>
<div className={styles.newCanvasInner}>
<span className={styles.newIcon}><Sparkles size={22} color="#534AB7" /></span>
<h2 className={styles.newTitle}>{isNew ? 'Build a dashboard' : 'Nothing built here yet'}</h2>
<p className={styles.newSub}>
{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.'}
</p>

<div className={styles.exampleGrid}>
{EXAMPLE_PROMPTS.map(({ icon: Icon, title, prompt }) => (
<button
key={title}
className={styles.exampleCard}
onClick={() => submit(prompt)}
disabled={thinking}
>
<span className={styles.exampleIcon}><Icon size={16} /></span>
<span className={styles.exampleTitle}>{title}</span>
<span className={styles.examplePrompt}>{prompt}</span>
</button>
))}
</div>

<button className={styles.backLink} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>
</div>
</div>
)}
</main>
Expand Down
84 changes: 81 additions & 3 deletions src/components/sections/DashboardWorkspace.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
128 changes: 96 additions & 32 deletions src/components/sections/DashboardsHome.jsx
Original file line number Diff line number Diff line change
@@ -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 ''
Expand All @@ -22,17 +17,35 @@ function relTime(iso) {
return `${Math.round(h / 24)}d ago`
}

function Thumb({ charts }) {
if (charts <= 0) {
return <svg viewBox="0 0 160 64" width="100%" height="64" preserveAspectRatio="none" aria-hidden="true">
<path d="M2,46 L40,48 L78,38 L116,42 L158,24 L158,64 L2,64 Z" fill="#EEEDFE" />
<polyline points="2,46 40,48 78,38 116,42 158,24" fill="none" stroke="#7F77DD" strokeWidth="2" />
</svg>
}
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 (
<div className={styles.thumbBars}>
{heights.map((h, i) => <span key={i} style={{ height: `${h}%`, background: i === 2 ? '#534AB7' : i % 2 ? '#7F77DD' : '#AFA9EC' }} />)}
<div className={styles.thumbMock}>
<div className={styles.thumbMockKpis}>
<span className={styles.thumbMockKpi} />
<span className={styles.thumbMockKpi} />
<span className={styles.thumbMockKpi} />
</div>
<div className={styles.thumbBars}>
{heights.map((h, i) => (
<span key={i} style={{ height: `${Math.round(h * 0.34)}px` }} className={i === 2 ? styles.thumbBarAccent : styles.thumbBar} />
))}
</div>
</div>
)
}
Expand All @@ -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)
}
Expand All @@ -77,7 +106,12 @@ export default function DashboardsHome({ connectionId, onOpen }) {
return (
<div className={styles.root}>
<header className={styles.header}>
<h1 className={styles.title}>Dashboards</h1>
<div className={styles.headerText}>
<h1 className={styles.title}>Dashboards</h1>
{dashboards.length > 0 && (
<p className={styles.subtitle}>{dashboards.length} dashboard{dashboards.length === 1 ? '' : 's'} for this connection</p>
)}
</div>
<div className={styles.actions}>
<button className={styles.ghost} onClick={load} title="Refresh" aria-label="Refresh">
<RefreshCw size={15} className={loading ? styles.spin : undefined} />
Expand All @@ -96,6 +130,16 @@ export default function DashboardsHome({ connectionId, onOpen }) {
))}
</div>

{!loading && dashboards.length === 0 ? (
<div className={styles.emptyState}>
<span className={styles.emptyIcon}><LayoutDashboard size={22} /></span>
<h2 className={styles.emptyTitle}>No dashboards yet</h2>
<p className={styles.emptySub}>Describe what you want to see and the DeepSQL agent will build it — grounded on your schema, verified against your data.</p>
<button className={styles.primary} onClick={() => onOpen('new')}>
<Plus size={15} /> New dashboard
</button>
</div>
) : (
<div className={styles.grid}>
{shown.map((d) => (
<div
Expand All @@ -106,16 +150,27 @@ export default function DashboardsHome({ connectionId, onOpen }) {
onClick={() => onOpen(d)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(d) } }}
>
<button
className={styles.cardDelete}
onClick={(e) => remove(d, e)}
disabled={deletingId === d.id}
title="Delete dashboard"
aria-label={`Delete ${d.name || 'dashboard'}`}
>
{deletingId === d.id ? <Loader2 size={14} className={styles.spin} /> : <Trash2 size={14} />}
</button>
<div className={styles.thumb}><Thumb charts={chartCount(d)} /></div>
<div className={styles.deleteWrap} ref={confirmId === d.id ? confirmRef : undefined}>
<button
className={styles.cardDelete}
onClick={(e) => { e.stopPropagation(); if (!deletingId) setConfirmId(d.id) }}
disabled={deletingId === d.id}
title="Delete dashboard"
aria-label={`Delete ${d.name || 'dashboard'}`}
>
{deletingId === d.id ? <Loader2 size={14} className={styles.spin} /> : <Trash2 size={14} />}
</button>
{confirmId === d.id && (
<div className={styles.confirmPopover} onClick={(e) => e.stopPropagation()}>
<p className={styles.confirmText}>Delete “{d.name || 'this dashboard'}”? This can’t be undone.</p>
<div className={styles.confirmActions}>
<button className={styles.confirmCancel} onClick={() => setConfirmId(null)}>Cancel</button>
<button className={styles.confirmDelete} onClick={(e) => remove(d, e)}>Delete</button>
</div>
</div>
)}
</div>
<div className={styles.thumb}><Thumb id={d.id} /></div>
<div className={styles.cardBody}>
<div className={styles.cardTop}>
<span className={styles.cardName}>{d.name || 'Untitled'}</span>
Expand All @@ -134,9 +189,18 @@ export default function DashboardsHome({ connectionId, onOpen }) {
<span className={styles.ctaSub}>“Build a revenue dashboard for last quarter”</span>
</button>
</div>
)}

{!loading && dashboards.length > 0 && shown.length === 0 && (
<div className={styles.emptyNote}>No {filter} dashboards — try a different filter.</div>
)}

{!loading && shown.length === 0 && (
<div className={styles.emptyNote}>No dashboards here yet — create one, or ask the agent to build it.</div>
{deleteError && (
<div className={styles.errorToast}>
<AlertTriangle size={14} />
<span>{deleteError}</span>
<button className={styles.errorDismiss} onClick={() => setDeleteError(null)} aria-label="Dismiss">×</button>
</div>
)}
</div>
)
Expand Down
Loading
Loading