From 32075df650520c7278979f504179c24d630aa6c7 Mon Sep 17 00:00:00 2001 From: Simantak Dabhade Date: Wed, 27 May 2026 15:24:29 -0700 Subject: [PATCH 1/6] Polish navbar, dataset toolbar, and theme UI - Replace inline user info with a profile menu popover (avatar, dark mode toggle, sign out) - Dataset page: avatar-only profile trigger, export dropdown, rounded action buttons with icons, confirm modal for Clear & Populate - StatusBadge: pill shape (rounded-full) - Theme-aware link color: softer pastel blue in dark mode - Extract useTheme hook from ThemeToggle for reuse Co-Authored-By: Claude Opus 4.6 --- frontend/app/dashboard/page.tsx | 107 ++++++- frontend/app/dataset/[id]/page.tsx | 328 ++++++++++++++++---- frontend/app/globals.css | 7 + frontend/components/ThemeToggle.tsx | 14 +- frontend/components/dataset/StatusBadge.tsx | 2 +- frontend/components/table/CellValue.tsx | 5 +- frontend/components/table/ColumnIcon.tsx | 2 +- 7 files changed, 389 insertions(+), 76 deletions(-) diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index cc94b78..a32a792 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -5,11 +5,12 @@ import Link from "next/link"; import { useQuery, useConvexAuth } from "convex/react"; import { useUser, useClerk } from "@clerk/nextjs"; import { api } from "@/convex/_generated/api"; +import type { UserResource } from "@clerk/types"; import { DatasetCard, type DatasetCardData, } from "@/components/dataset/DatasetCard"; -import { ThemeToggle } from "@/components/ThemeToggle"; +import { useTheme } from "@/components/ThemeToggle"; import { QuotaBadge } from "@/components/QuotaBadge"; import { EVENTS, track } from "@/lib/analytics"; @@ -85,19 +86,7 @@ export default function DashboardPage() {
- -
- {/* PII: mask the email in session replays */} - - {user?.primaryEmailAddress?.emailAddress} - -
- + signOut()} />
@@ -257,3 +246,93 @@ function Section({
); } + +function ProfileMenu({ + user, + onSignOut, +}: { + user: UserResource | null | undefined; + onSignOut: () => void; +}) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) + setOpen(false); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + const { theme, toggle: toggleTheme } = useTheme(); + const name = user?.fullName || user?.firstName || "User"; + const email = user?.primaryEmailAddress?.emailAddress; + const imageUrl = user?.imageUrl; + + return ( +
+ + + {open && ( +
+
+

{name}

+ {email && ( +

+ {email} +

+ )} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 56a473a..18249f5 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -4,12 +4,13 @@ import { useParams } from "next/navigation"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useConvexAuth } from "convex/react"; -import { useAuth } from "@clerk/nextjs"; +import { useAuth, useUser, useClerk } from "@clerk/nextjs"; +import type { UserResource } from "@clerk/types"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { DatasetTable } from "@/components/table"; import { useSelection } from "@/components/table/use-selection"; -import { ThemeToggle } from "@/components/ThemeToggle"; +import { useTheme } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate, update } from "@/lib/backend"; @@ -19,9 +20,13 @@ export default function DatasetPage() { const params = useParams(); const { isLoading: authLoading } = useConvexAuth(); const { userId, getToken } = useAuth(); + const { user } = useUser(); + const { signOut } = useClerk(); const [exporting, setExporting] = useState<"csv" | "xlsx" | null>(null); const [populating, setPopulating] = useState(false); const [updating, setUpdating] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [confirmPopulate, setConfirmPopulate] = useState(false); const datasetId = params.id as Id<"datasets">; const dataset = useQuery( @@ -37,9 +42,8 @@ export default function DatasetPage() { const selection = useSelection(rowIds); const selectedCount = selection.selected.size; - // Fire dataset_opened once per dataset visit, after the dataset has - // resolved. The ref keeps it idempotent across re-renders. const openedFired = useRef(null); + const autoPopulateFired = useRef(null); useEffect(() => { if (dataset && openedFired.current !== dataset._id) { openedFired.current = dataset._id; @@ -50,6 +54,16 @@ export default function DatasetPage() { is_owner: userId === dataset.ownerId, }); } + if ( + dataset && + autoPopulateFired.current !== dataset._id && + dataset.status === "paused" && + (dataset.rowCount ?? 0) === 0 && + userId === dataset.ownerId + ) { + autoPopulateFired.current = dataset._id; + handlePopulate(); + } }, [dataset, userId]); async function handleExport(format: "csv" | "xlsx") { @@ -175,77 +189,71 @@ export default function DatasetPage() { : dataset.status === "failed" ? "Retry Populate" : "Clear & Populate"; - const csvLabel = - exporting === "csv" - ? "Exporting…" - : selectedCount > 0 - ? `Export CSV (${selectedCount})` - : "Export CSV"; - const xlsxLabel = - exporting === "xlsx" - ? "Exporting…" - : selectedCount > 0 - ? `Export XLSX (${selectedCount})` - : "Export XLSX"; + const exportLabel = exporting + ? "Exporting…" + : selectedCount > 0 + ? `Export (${selectedCount})` + : "Export"; return (
-
-
- - BigSet - BigSet +
+
+ + BigSet + BigSet - / +

{dataset.name}

-
- +
+ {dataset.cadence} - - + exporting={exporting} + selectedCount={selectedCount} + onExport={(fmt) => { setExportOpen(false); handleExport(fmt); }} + /> + + -
- + +
+ + signOut()} />
@@ -281,6 +289,222 @@ export default function DatasetPage() { datasetId={datasetId} selection={selection} /> + + {confirmPopulate && ( + { + setConfirmPopulate(false); + handlePopulate(); + }} + onCancel={() => setConfirmPopulate(false)} + /> + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Export dropdown */ +/* ------------------------------------------------------------------ */ + +function ExportDropdown({ + open, + onToggle, + onClose, + label, + disabled, + exporting, + selectedCount, + onExport, +}: { + open: boolean; + onToggle: () => void; + onClose: () => void; + label: string; + disabled: boolean; + exporting: "csv" | "xlsx" | null; + selectedCount: number; + onExport: (fmt: "csv" | "xlsx") => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open, onClose]); + + const hint = selectedCount > 0 + ? `${selectedCount} row${selectedCount === 1 ? "" : "s"} selected` + : "All rows"; + + return ( +
+ + + {open && ( +
+
+

{hint}

+
+
+ + +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Profile menu (matches dashboard) */ +/* ------------------------------------------------------------------ */ + +function DatasetProfileMenu({ + user, + onSignOut, +}: { + user: UserResource | null | undefined; + onSignOut: () => void; +}) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const { theme, toggle: toggleTheme } = useTheme(); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) + setOpen(false); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + const name = user?.fullName || user?.firstName || "User"; + const email = user?.primaryEmailAddress?.emailAddress; + const imageUrl = user?.imageUrl; + + return ( +
+ + + {open && ( +
+
+

{name}

+ {email && ( +

{email}

+ )} +
+
+ + +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Confirm populate modal */ +/* ------------------------------------------------------------------ */ + +function ConfirmPopulateModal({ + rowCount, + onConfirm, + onCancel, +}: { + rowCount: number; + onConfirm: () => void; + onCancel: () => void; +}) { + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [onCancel]); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + > +
+

+ This will delete {rowCount === 1 ? "1 row" : `${rowCount} rows`}. This can't be undone. +

+
+ + +
+
); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e62cd07..1b9363a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -11,6 +11,9 @@ --muted: #7c7f74; --accent: #1d1b16; --accent-text: rgb(243, 244, 240); + --link: #2563eb; + --link-decoration: rgba(37, 99, 235, 0.3); + --link-decoration-hover: rgba(37, 99, 235, 0.6); --table-row-height: 34px; --table-header-height: 32px; --table-cell-px: 12px; @@ -25,6 +28,9 @@ --muted: #7a756c; --accent: #e8e4de; --accent-text: #141210; + --link: #93b4f5; + --link-decoration: rgba(147, 180, 245, 0.25); + --link-decoration-hover: rgba(147, 180, 245, 0.5); } @theme inline { @@ -35,6 +41,7 @@ --color-muted: var(--muted); --color-accent: var(--accent); --color-accent-text: var(--accent-text); + --color-link: var(--link); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } diff --git a/frontend/components/ThemeToggle.tsx b/frontend/components/ThemeToggle.tsx index c0b54d8..346a509 100644 --- a/frontend/components/ThemeToggle.tsx +++ b/frontend/components/ThemeToggle.tsx @@ -44,7 +44,7 @@ function readServerTheme(): Theme { return "light"; } -export function ThemeToggle({ className = "" }: { className?: string }) { +export function useTheme() { const theme = useSyncExternalStore( subscribeToThemeChange, readEffectiveTheme, @@ -56,14 +56,16 @@ export function ThemeToggle({ className = "" }: { className?: string }) { applyTheme(next); try { window.localStorage.setItem(STORAGE_KEY, next); - } catch { - // localStorage may be blocked (Safari private mode etc.) — toggle - // still works for the session, just doesn't persist. - } + } catch {} window.dispatchEvent(new Event(THEME_CHANGED_EVENT)); track(EVENTS.THEME_CHANGED, { theme: next }); } + return { theme, toggle } as const; +} + +export function ThemeToggle({ className = "" }: { className?: string }) { + const { theme, toggle } = useTheme(); const label = theme === "dark" ? "Switch to light mode" : "Switch to dark mode"; return ( @@ -74,8 +76,6 @@ export function ThemeToggle({ className = "" }: { className?: string }) { title={label} className={`inline-flex items-center justify-center h-7 w-7 text-muted hover:text-foreground transition-colors ${className}`} > - {/* Both icons rendered, one shown based on theme. Avoids a flash - when switching since neither has to mount/unmount. */} {theme === "dark" ? : } ); diff --git a/frontend/components/dataset/StatusBadge.tsx b/frontend/components/dataset/StatusBadge.tsx index 5fd7cf2..81f71d7 100644 --- a/frontend/components/dataset/StatusBadge.tsx +++ b/frontend/components/dataset/StatusBadge.tsx @@ -17,7 +17,7 @@ const LABELS: Record = { export function StatusBadge({ status }: { status: DatasetStatus }) { return ( {status === "live" && ( diff --git a/frontend/components/table/CellValue.tsx b/frontend/components/table/CellValue.tsx index b7707f3..3a4405c 100644 --- a/frontend/components/table/CellValue.tsx +++ b/frontend/components/table/CellValue.tsx @@ -34,7 +34,10 @@ export function CellValue({ href={safeUrl} target="_blank" rel="noopener noreferrer" - className="text-blue-600 underline underline-offset-2 decoration-blue-600/30 hover:decoration-blue-600/60" + className="text-link underline underline-offset-2" + style={{ textDecorationColor: "var(--link-decoration)" }} + onMouseEnter={(e) => (e.currentTarget.style.textDecorationColor = "var(--link-decoration-hover)")} + onMouseLeave={(e) => (e.currentTarget.style.textDecorationColor = "var(--link-decoration)")} title={str} onClick={(e) => e.stopPropagation()} > diff --git a/frontend/components/table/ColumnIcon.tsx b/frontend/components/table/ColumnIcon.tsx index abe7c66..e1c94e3 100644 --- a/frontend/components/table/ColumnIcon.tsx +++ b/frontend/components/table/ColumnIcon.tsx @@ -12,7 +12,7 @@ const colors: Record = { text: "text-foreground/30", number: "text-violet-500/70", boolean: "text-emerald-500/70", - url: "text-blue-500/70", + url: "text-link/70", date: "text-amber-500/70", }; From da759b99a7a5f8d6d7cbe0ecb1736f4916bcdb46 Mon Sep 17 00:00:00 2001 From: Simantak Dabhade Date: Wed, 27 May 2026 15:37:15 -0700 Subject: [PATCH 2/6] Address CodeRabbit accessibility feedback - Dashboard ProfileMenu: add aria-haspopup, aria-expanded, aria-controls, role="menu" - Dataset page: wrap handlePopulate in useCallback, add to effect deps - ConfirmPopulateModal: add role="dialog", aria-modal, aria-labelledby - CellValue link: add onFocus/onBlur for keyboard underline feedback Co-Authored-By: Claude Opus 4.6 --- frontend/app/dashboard/page.tsx | 6 +++++- frontend/app/dataset/[id]/page.tsx | 13 +++++++------ frontend/components/table/CellValue.tsx | 2 ++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index a32a792..83cc2b7 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -275,7 +275,11 @@ function ProfileMenu({ return (
{open && ( -
+