diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index cc94b78..83cc2b7 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,97 @@ 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 && ( + + )} +
+ ); +} diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 56a473a..cc98ef5 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -2,14 +2,15 @@ import { useParams } from "next/navigation"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, 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,14 @@ 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 [settingsOpen, setSettingsOpen] = useState(false); + const [confirmPopulate, setConfirmPopulate] = useState(false); const datasetId = params.id as Id<"datasets">; const dataset = useQuery( @@ -37,9 +43,38 @@ 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 handlePopulate = useCallback(async () => { + if (!dataset || populating || dataset.status === "building") return; + setPopulating(true); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + + const startedRun = await populate( + dataset._id, + dataset.name, + dataset.description, + dataset.columns, + token, + ); + track(EVENTS.DATASET_POPULATE_STARTED, { + datasetId: dataset._id, + column_count: dataset.columns.length, + runId: startedRun.runId, + }); + } catch (err) { + console.error("[populate] failed", err); + captureException(err, { + operation: "dataset_populate", + datasetId: dataset._id, + }); + } finally { + setPopulating(false); + } + }, [dataset, populating, getToken]); + const openedFired = useRef(null); + const autoPopulateFired = useRef(null); useEffect(() => { if (dataset && openedFired.current !== dataset._id) { openedFired.current = dataset._id; @@ -50,14 +85,21 @@ export default function DatasetPage() { is_owner: userId === dataset.ownerId, }); } - }, [dataset, userId]); + if ( + dataset && + autoPopulateFired.current !== dataset._id && + dataset.status === "paused" && + (dataset.rowCount ?? 0) === 0 && + userId === dataset.ownerId + ) { + autoPopulateFired.current = dataset._id; + handlePopulate(); + } + }, [dataset, userId, handlePopulate]); async function handleExport(format: "csv" | "xlsx") { if (!dataset || !rows || exporting) return; - // If the user has rows selected, export ONLY those. Otherwise the - // entire dataset. Preserves column ordering (handled by the export - // util — it iterates `dataset.columns` in order). const exportRows = selectedCount > 0 ? rows.filter((r) => selection.selected.has(r._id)) @@ -117,36 +159,6 @@ export default function DatasetPage() { } } - async function handlePopulate() { - if (!dataset || populating || dataset.status === "building") return; - setPopulating(true); - try { - const token = await getToken(); - if (!token) throw new Error("Not authenticated"); - - const startedRun = await populate( - dataset._id, - dataset.name, - dataset.description, - dataset.columns, - token, - ); - track(EVENTS.DATASET_POPULATE_STARTED, { - datasetId: dataset._id, - column_count: dataset.columns.length, - runId: startedRun.runId, - }); - } catch (err) { - console.error("[populate] failed", err); - captureException(err, { - operation: "dataset_populate", - datasetId: dataset._id, - }); - } finally { - setPopulating(false); - } - } - if (authLoading || dataset === undefined || rows === undefined) { return (
@@ -175,77 +187,63 @@ 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); }} + /> + + setSettingsOpen((o) => !o)} + onClose={() => setSettingsOpen(false)} + cadence={dataset.cadence} + updateLabel={updateLabel} + updateDisabled={updateDisabled} + populateLabel={populateLabel} + populateDisabled={populateDisabled} + onUpdate={() => { setSettingsOpen(false); handleUpdate(); }} + onPopulate={() => { + setSettingsOpen(false); + if (rows.length > 0) { + setConfirmPopulate(true); + } else { + handlePopulate(); + } + }} + /> + +
+ + signOut()} />
@@ -281,6 +279,301 @@ 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}

+
+
+ + +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Settings dropdown */ +/* ------------------------------------------------------------------ */ + +function SettingsDropdown({ + open, + onToggle, + onClose, + cadence, + updateLabel, + updateDisabled, + populateLabel, + populateDisabled, + onUpdate, + onPopulate, +}: { + open: boolean; + onToggle: () => void; + onClose: () => void; + cadence: string; + updateLabel: string; + updateDisabled: boolean; + populateLabel: string; + populateDisabled: boolean; + onUpdate: () => void; + onPopulate: () => 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]); + + return ( +
+ + + {open && ( +
+
+ + +
+
+ {cadence} +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* 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(); }} + role="presentation" + > +
+

+ 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..15cf9ed 100644 --- a/frontend/components/table/CellValue.tsx +++ b/frontend/components/table/CellValue.tsx @@ -34,7 +34,12 @@ 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)")} + onFocus={(e) => (e.currentTarget.style.textDecorationColor = "var(--link-decoration-hover)")} + onBlur={(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", };