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 (
-
-
-
-

-

+
+
+
+

+

-
/
+
{dataset.name}
-
-
- {dataset.cadence}
-
-
-
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* 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 (
+
+
+
+ {label}
+
+
+
+ {open && (
+
+
+
+ onExport("csv")}
+ disabled={exporting !== null}
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-foreground hover:bg-foreground/[0.05] transition-colors disabled:opacity-40"
+ >
+ .csv
+ {exporting === "csv" ? "Exporting…" : "Comma-separated"}
+
+ onExport("xlsx")}
+ disabled={exporting !== null}
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-foreground hover:bg-foreground/[0.05] transition-colors disabled:opacity-40"
+ >
+ .xlsx
+ {exporting === "xlsx" ? "Exporting…" : "Excel spreadsheet"}
+
+
+
+ )}
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* 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 (
+
+
+
+ Settings
+
+
+
+ {open && (
+
+
+
+
+ {updateLabel}
+
+
+
+ {populateLabel}
+
+
+
+ {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 (
+
+
setOpen((o) => !o)}
+ className="rounded-full p-0.5 hover:bg-foreground/[0.05] transition-colors"
+ aria-label="Profile menu"
+ >
+ {imageUrl ? (
+
+ ) : (
+
+ {name[0]?.toUpperCase()}
+
+ )}
+
+
+ {open && (
+
+
+
{name}
+ {email && (
+
{email}
+ )}
+
+
+
+ Dark mode
+
+
+
+
+
{ setOpen(false); onSignOut(); }}
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs text-red-500 hover:bg-red-500/[0.08] transition-colors"
+ >
+
+ Sign out
+
+
+
+ )}
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* 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.
+
+
+
+ Cancel
+
+
+ Delete & populate
+
+
+
);
}
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",
};