dbdiff-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/cli.js +83 -0
- package/bin/install-local.js +57 -0
- package/electron/generate-icon.mjs +54 -0
- package/electron/icon.icns +0 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +21 -0
- package/electron/main.js +169 -0
- package/electron/patch-dev-plist.js +31 -0
- package/electron/preload.cjs +18 -0
- package/electron/wait-for-vite.js +43 -0
- package/index.html +13 -0
- package/package.json +91 -0
- package/public/favicon.svg +15 -0
- package/public/vite.svg +1 -0
- package/server/export.ts +57 -0
- package/server/index.ts +392 -0
- package/src/App.css +1 -0
- package/src/App.tsx +543 -0
- package/src/assets/react.svg +1 -0
- package/src/components/CommandPalette.tsx +243 -0
- package/src/components/ConnectedView.tsx +78 -0
- package/src/components/ConnectionPicker.tsx +381 -0
- package/src/components/ConsoleView.tsx +360 -0
- package/src/components/CsvExportModal.tsx +144 -0
- package/src/components/DataGrid/DataGrid.tsx +262 -0
- package/src/components/DataGrid/DataGridCell.tsx +73 -0
- package/src/components/DataGrid/DataGridHeader.tsx +89 -0
- package/src/components/DataGrid/index.ts +20 -0
- package/src/components/DataGrid/types.ts +63 -0
- package/src/components/DataGrid/useColumnResize.ts +153 -0
- package/src/components/DataGrid/useDataGridSelection.ts +340 -0
- package/src/components/DataGrid/utils.ts +184 -0
- package/src/components/DatabaseMenu.tsx +93 -0
- package/src/components/DatabaseSwitcher.tsx +208 -0
- package/src/components/DiffView.tsx +215 -0
- package/src/components/EditConnectionModal.tsx +417 -0
- package/src/components/ErrorBoundary.tsx +69 -0
- package/src/components/GlobalShortcuts.tsx +201 -0
- package/src/components/InnerTabBar.tsx +129 -0
- package/src/components/JsonTreeViewer.tsx +387 -0
- package/src/components/MemberAccessEditor.tsx +443 -0
- package/src/components/MembersModal.tsx +446 -0
- package/src/components/NewConnectionModal.tsx +274 -0
- package/src/components/Resizer.tsx +66 -0
- package/src/components/ScanSuccessModal.tsx +113 -0
- package/src/components/ShortcutSettingsModal.tsx +318 -0
- package/src/components/Sidebar.tsx +532 -0
- package/src/components/TabBar.tsx +188 -0
- package/src/components/TableView.tsx +2147 -0
- package/src/components/ThemeToggle.tsx +44 -0
- package/src/components/index.ts +17 -0
- package/src/constants.ts +12 -0
- package/src/electron.d.ts +12 -0
- package/src/index.css +44 -0
- package/src/main.tsx +13 -0
- package/src/stores/hooks.ts +1146 -0
- package/src/stores/index.ts +12 -0
- package/src/stores/store.ts +1514 -0
- package/src/stores/useCloudSync.ts +274 -0
- package/src/stores/useSyncDatabase.ts +422 -0
- package/src/types.ts +277 -0
- package/src/utils/csv.ts +27 -0
- package/src/vite-env.d.ts +2 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/tsconfig.server.json +14 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import CodeMirror from "@uiw/react-codemirror";
|
|
2
|
+
import { PostgreSQL, sql, type SQLNamespace } from "@codemirror/lang-sql";
|
|
3
|
+
import { keymap } from "@codemirror/view";
|
|
4
|
+
import { Download } from "lucide-react";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
formatShortcutDisplay,
|
|
8
|
+
useActiveDatabaseConfig,
|
|
9
|
+
useConsoleDiff,
|
|
10
|
+
useConsoleExecution,
|
|
11
|
+
useConsoleState,
|
|
12
|
+
useShortcut,
|
|
13
|
+
} from "../stores/hooks";
|
|
14
|
+
import { useStore } from "../stores/store";
|
|
15
|
+
import { CsvExportModal } from "./CsvExportModal";
|
|
16
|
+
import { DiffView } from "./DiffView";
|
|
17
|
+
import { Resizer } from "./Resizer";
|
|
18
|
+
import { DataGrid } from "./DataGrid";
|
|
19
|
+
|
|
20
|
+
/** Convert our shortcut format to CodeMirror's format */
|
|
21
|
+
function toCodeMirrorKey(shortcut: string): string {
|
|
22
|
+
return shortcut
|
|
23
|
+
.split("+")
|
|
24
|
+
.map((part) => {
|
|
25
|
+
const lower = part.toLowerCase();
|
|
26
|
+
if (lower === "mod") return "Mod";
|
|
27
|
+
if (lower === "ctrl") return "Ctrl";
|
|
28
|
+
if (lower === "alt") return "Alt";
|
|
29
|
+
if (lower === "shift") return "Shift";
|
|
30
|
+
if (lower === "enter") return "Enter";
|
|
31
|
+
if (lower === "escape") return "Escape";
|
|
32
|
+
return part;
|
|
33
|
+
})
|
|
34
|
+
.join("-");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ConsoleViewProps {
|
|
38
|
+
tabId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ConsoleView({ tabId }: ConsoleViewProps) {
|
|
42
|
+
const consoleState = useConsoleState(tabId);
|
|
43
|
+
const setConsoleQueryText = useStore((state) => state.setConsoleQueryText);
|
|
44
|
+
const initConsoleState = useStore((state) => state.initConsoleState);
|
|
45
|
+
const runQueryShortcut = useShortcut("runQuery");
|
|
46
|
+
const { execute } = useConsoleExecution(tabId);
|
|
47
|
+
const { executeDiff } = useConsoleDiff(tabId);
|
|
48
|
+
const isDark = useStore((state) => state.darkMode);
|
|
49
|
+
const databaseConfig = useActiveDatabaseConfig();
|
|
50
|
+
const [editorHeight, setEditorHeight] = useState(200);
|
|
51
|
+
const [showCsvExport, setShowCsvExport] = useState(false);
|
|
52
|
+
|
|
53
|
+
// Build CodeMirror schema namespace from cached database metadata
|
|
54
|
+
const sqlSchema = useMemo((): SQLNamespace | undefined => {
|
|
55
|
+
const schemas = databaseConfig?.cache?.schemas;
|
|
56
|
+
if (!schemas?.length) return undefined;
|
|
57
|
+
const ns: {
|
|
58
|
+
[schema: string]: {
|
|
59
|
+
[table: string]: { label: string; type: string; detail: string }[];
|
|
60
|
+
};
|
|
61
|
+
} = {};
|
|
62
|
+
for (const schema of schemas) {
|
|
63
|
+
const tables: {
|
|
64
|
+
[table: string]: { label: string; type: string; detail: string }[];
|
|
65
|
+
} = {};
|
|
66
|
+
for (const table of schema.tables) {
|
|
67
|
+
tables[table.name] = table.columns.map((col) => ({
|
|
68
|
+
label: col.name,
|
|
69
|
+
type: "property",
|
|
70
|
+
detail: col.dataType,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
ns[schema.name] = tables;
|
|
74
|
+
}
|
|
75
|
+
return ns;
|
|
76
|
+
}, [databaseConfig?.cache?.schemas]);
|
|
77
|
+
|
|
78
|
+
const sqlExtension = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
sql({
|
|
81
|
+
dialect: PostgreSQL,
|
|
82
|
+
schema: sqlSchema,
|
|
83
|
+
defaultSchema: "public",
|
|
84
|
+
}),
|
|
85
|
+
[sqlSchema],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const handleEditorResize = useCallback((delta: number) => {
|
|
89
|
+
setEditorHeight((h) =>
|
|
90
|
+
Math.max(100, Math.min(h + delta, window.innerHeight - 200)),
|
|
91
|
+
);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Initialize state on mount if not exists
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
initConsoleState(tabId);
|
|
97
|
+
}, [tabId, initConsoleState]);
|
|
98
|
+
|
|
99
|
+
// Create keybinding for run query (configurable)
|
|
100
|
+
const executeKeymap = useMemo(
|
|
101
|
+
() =>
|
|
102
|
+
keymap.of([
|
|
103
|
+
{
|
|
104
|
+
key: toCodeMirrorKey(runQueryShortcut),
|
|
105
|
+
run: () => {
|
|
106
|
+
execute();
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
]),
|
|
111
|
+
[execute, runQueryShortcut],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// On Mac, also accept Cmd+Enter when shortcut is Ctrl+Enter.
|
|
115
|
+
// Native capture-phase listener so we intercept before CodeMirror.
|
|
116
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
const el = editorRef.current;
|
|
119
|
+
if (!el) return;
|
|
120
|
+
const isMac = navigator.platform.toUpperCase().includes("MAC");
|
|
121
|
+
if (!isMac) return;
|
|
122
|
+
|
|
123
|
+
const handler = (e: KeyboardEvent) => {
|
|
124
|
+
if (e.metaKey && !e.ctrlKey && e.key === "Enter") {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
execute();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
el.addEventListener("keydown", handler, { capture: true });
|
|
131
|
+
return () => el.removeEventListener("keydown", handler, { capture: true });
|
|
132
|
+
}, [execute]);
|
|
133
|
+
|
|
134
|
+
const handleChange = (value: string) => {
|
|
135
|
+
setConsoleQueryText(tabId, value);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const { status, result, error, diffResult, lastAction } = consoleState;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="h-full w-full flex flex-col">
|
|
142
|
+
{/* Toolbar */}
|
|
143
|
+
<div className="flex-shrink-0 flex items-center gap-2 px-3 py-2 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
|
|
144
|
+
<button
|
|
145
|
+
onClick={execute}
|
|
146
|
+
disabled={status === "executing" || !consoleState.queryText.trim()}
|
|
147
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] font-medium rounded-md bg-stone-800 dark:bg-white text-white dark:text-stone-900 hover:bg-stone-700 dark:hover:bg-stone-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
148
|
+
>
|
|
149
|
+
{status === "executing" ? (
|
|
150
|
+
<>
|
|
151
|
+
<svg
|
|
152
|
+
className="animate-spin h-3.5 w-3.5"
|
|
153
|
+
viewBox="0 0 24 24"
|
|
154
|
+
fill="none"
|
|
155
|
+
>
|
|
156
|
+
<circle
|
|
157
|
+
className="opacity-25"
|
|
158
|
+
cx="12"
|
|
159
|
+
cy="12"
|
|
160
|
+
r="10"
|
|
161
|
+
stroke="currentColor"
|
|
162
|
+
strokeWidth="4"
|
|
163
|
+
/>
|
|
164
|
+
<path
|
|
165
|
+
className="opacity-75"
|
|
166
|
+
fill="currentColor"
|
|
167
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
168
|
+
/>
|
|
169
|
+
</svg>
|
|
170
|
+
Running...
|
|
171
|
+
</>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<svg
|
|
175
|
+
className="w-3.5 h-3.5"
|
|
176
|
+
viewBox="0 0 24 24"
|
|
177
|
+
fill="currentColor"
|
|
178
|
+
>
|
|
179
|
+
<path d="M8 5v14l11-7z" />
|
|
180
|
+
</svg>
|
|
181
|
+
Run
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</button>
|
|
185
|
+
<span className="text-[11px] text-tertiary">
|
|
186
|
+
{formatShortcutDisplay(runQueryShortcut)}
|
|
187
|
+
</span>
|
|
188
|
+
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1" />
|
|
189
|
+
<button
|
|
190
|
+
onClick={executeDiff}
|
|
191
|
+
disabled={status === "executing" || !consoleState.queryText.trim()}
|
|
192
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] font-medium rounded-md border border-stone-300 dark:border-white/15 text-secondary hover:bg-stone-100 dark:hover:bg-white/[0.06] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
193
|
+
>
|
|
194
|
+
<svg
|
|
195
|
+
className="w-3.5 h-3.5"
|
|
196
|
+
viewBox="0 0 24 24"
|
|
197
|
+
fill="none"
|
|
198
|
+
stroke="currentColor"
|
|
199
|
+
strokeWidth="2"
|
|
200
|
+
>
|
|
201
|
+
<path
|
|
202
|
+
d="M12 3v18M3 12h18M3 6h8M13 6h8M3 18h8M13 18h8"
|
|
203
|
+
strokeLinecap="round"
|
|
204
|
+
/>
|
|
205
|
+
</svg>
|
|
206
|
+
Diff
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{/* Editor section */}
|
|
211
|
+
<div
|
|
212
|
+
ref={editorRef}
|
|
213
|
+
className="flex-shrink-0"
|
|
214
|
+
style={{ height: editorHeight }}
|
|
215
|
+
>
|
|
216
|
+
<CodeMirror
|
|
217
|
+
className="h-full"
|
|
218
|
+
key={isDark ? "dark" : "light"}
|
|
219
|
+
value={consoleState.queryText}
|
|
220
|
+
onChange={handleChange}
|
|
221
|
+
height="100%"
|
|
222
|
+
autoFocus
|
|
223
|
+
theme={isDark ? "dark" : "light"}
|
|
224
|
+
extensions={[sqlExtension, executeKeymap]}
|
|
225
|
+
placeholder="-- Write your SQL query here... (Cmd/Ctrl+Enter to run)"
|
|
226
|
+
basicSetup={{
|
|
227
|
+
lineNumbers: true,
|
|
228
|
+
highlightActiveLineGutter: false,
|
|
229
|
+
highlightActiveLine: true,
|
|
230
|
+
foldGutter: false,
|
|
231
|
+
dropCursor: true,
|
|
232
|
+
allowMultipleSelections: true,
|
|
233
|
+
indentOnInput: true,
|
|
234
|
+
bracketMatching: true,
|
|
235
|
+
closeBrackets: true,
|
|
236
|
+
autocompletion: true,
|
|
237
|
+
rectangularSelection: true,
|
|
238
|
+
crosshairCursor: false,
|
|
239
|
+
highlightSelectionMatches: true,
|
|
240
|
+
searchKeymap: true,
|
|
241
|
+
}}
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<Resizer direction="vertical" onResize={handleEditorResize} />
|
|
246
|
+
|
|
247
|
+
{/* Results section */}
|
|
248
|
+
<div className="flex-1 min-h-0 overflow-auto border-t border-stone-200 dark:border-white/[0.06]">
|
|
249
|
+
{status === "idle" && (
|
|
250
|
+
<div className="flex items-center justify-center h-full text-tertiary text-[13px]">
|
|
251
|
+
Press Cmd/Ctrl+Enter to run query
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{status === "executing" && (
|
|
256
|
+
<div className="flex items-center justify-center h-full">
|
|
257
|
+
<div className="flex items-center gap-3 text-secondary text-[13px]">
|
|
258
|
+
<svg
|
|
259
|
+
className="animate-spin h-4 w-4"
|
|
260
|
+
viewBox="0 0 24 24"
|
|
261
|
+
fill="none"
|
|
262
|
+
>
|
|
263
|
+
<circle
|
|
264
|
+
className="opacity-25"
|
|
265
|
+
cx="12"
|
|
266
|
+
cy="12"
|
|
267
|
+
r="10"
|
|
268
|
+
stroke="currentColor"
|
|
269
|
+
strokeWidth="4"
|
|
270
|
+
/>
|
|
271
|
+
<path
|
|
272
|
+
className="opacity-75"
|
|
273
|
+
fill="currentColor"
|
|
274
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
275
|
+
/>
|
|
276
|
+
</svg>
|
|
277
|
+
Executing query...
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{status === "error" && error && (
|
|
283
|
+
<div className="p-4">
|
|
284
|
+
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 p-4">
|
|
285
|
+
<div className="flex items-start gap-3">
|
|
286
|
+
<svg
|
|
287
|
+
className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5"
|
|
288
|
+
viewBox="0 0 20 20"
|
|
289
|
+
fill="currentColor"
|
|
290
|
+
>
|
|
291
|
+
<path
|
|
292
|
+
fillRule="evenodd"
|
|
293
|
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
294
|
+
clipRule="evenodd"
|
|
295
|
+
/>
|
|
296
|
+
</svg>
|
|
297
|
+
<div>
|
|
298
|
+
<p className="text-[13px] font-medium text-red-800 dark:text-red-300">
|
|
299
|
+
Query Error
|
|
300
|
+
</p>
|
|
301
|
+
<p className="text-[13px] text-red-700 dark:text-red-400 mt-1 font-mono whitespace-pre-wrap">
|
|
302
|
+
{error}
|
|
303
|
+
</p>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
{status === "completed" && lastAction === "diff" && diffResult && (
|
|
311
|
+
<DiffView diffResult={diffResult} />
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{status === "completed" && lastAction !== "diff" && result && (
|
|
315
|
+
<div className="h-full flex flex-col">
|
|
316
|
+
{/* Result header */}
|
|
317
|
+
<div className="flex-shrink-0 flex items-center justify-between px-4 py-2 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
|
|
318
|
+
<span className="text-[12px] text-secondary">
|
|
319
|
+
{result.rowCount !== null
|
|
320
|
+
? `${result.rowCount} row${result.rowCount !== 1 ? "s" : ""}`
|
|
321
|
+
: "Query executed"}
|
|
322
|
+
{result.fields.length > 0 &&
|
|
323
|
+
` • ${result.fields.length} column${
|
|
324
|
+
result.fields.length !== 1 ? "s" : ""
|
|
325
|
+
}`}
|
|
326
|
+
</span>
|
|
327
|
+
{result.fields.length > 0 && (
|
|
328
|
+
<button
|
|
329
|
+
onClick={() => setShowCsvExport(true)}
|
|
330
|
+
className="p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-secondary transition-colors"
|
|
331
|
+
title="Export to CSV"
|
|
332
|
+
>
|
|
333
|
+
<Download className="w-4 h-4" />
|
|
334
|
+
</button>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* Result table */}
|
|
339
|
+
{result.fields.length > 0 ? (
|
|
340
|
+
<DataGrid columns={result.fields} rows={result.rows} />
|
|
341
|
+
) : (
|
|
342
|
+
<div className="flex items-center justify-center h-full text-tertiary text-[13px]">
|
|
343
|
+
Query executed successfully
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{showCsvExport && result && (
|
|
351
|
+
<CsvExportModal
|
|
352
|
+
onClose={() => setShowCsvExport(false)}
|
|
353
|
+
fields={result.fields}
|
|
354
|
+
currentRows={result.rows}
|
|
355
|
+
defaultFilename="query_results"
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useHotkey } from "../stores/hooks";
|
|
3
|
+
import { useStore } from "../stores/store";
|
|
4
|
+
import { generateCsv } from "../utils/csv";
|
|
5
|
+
|
|
6
|
+
interface CsvExportModalProps {
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
fields: { name: string }[];
|
|
9
|
+
currentRows: Record<string, unknown>[];
|
|
10
|
+
defaultFilename: string;
|
|
11
|
+
totalRowCount?: number;
|
|
12
|
+
fetchAllRows?: () => Promise<Record<string, unknown>[]>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CsvExportModal({
|
|
16
|
+
onClose,
|
|
17
|
+
fields,
|
|
18
|
+
currentRows,
|
|
19
|
+
defaultFilename,
|
|
20
|
+
totalRowCount,
|
|
21
|
+
fetchAllRows,
|
|
22
|
+
}: CsvExportModalProps) {
|
|
23
|
+
const csvExportPrefs = useStore((state) => state.csvExportPrefs);
|
|
24
|
+
const setCsvExportPrefs = useStore((state) => state.setCsvExportPrefs);
|
|
25
|
+
const [includeHeaders, setIncludeHeaders] = useState(
|
|
26
|
+
csvExportPrefs.includeHeaders,
|
|
27
|
+
);
|
|
28
|
+
const [scope, setScope] = useState<"current" | "all">(csvExportPrefs.scope);
|
|
29
|
+
const [exporting, setExporting] = useState(false);
|
|
30
|
+
|
|
31
|
+
useHotkey("closeModal", onClose);
|
|
32
|
+
|
|
33
|
+
async function doExport(destination: "file" | "clipboard") {
|
|
34
|
+
setExporting(true);
|
|
35
|
+
setCsvExportPrefs({ includeHeaders, scope });
|
|
36
|
+
try {
|
|
37
|
+
const rows =
|
|
38
|
+
scope === "all" && fetchAllRows ? await fetchAllRows() : currentRows;
|
|
39
|
+
const csv = generateCsv(fields, rows, { includeHeaders });
|
|
40
|
+
|
|
41
|
+
if (destination === "clipboard") {
|
|
42
|
+
await navigator.clipboard.writeText(csv);
|
|
43
|
+
} else {
|
|
44
|
+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
45
|
+
const url = URL.createObjectURL(blob);
|
|
46
|
+
const a = document.createElement("a");
|
|
47
|
+
a.href = url;
|
|
48
|
+
a.download = `${defaultFilename}.csv`;
|
|
49
|
+
document.body.appendChild(a);
|
|
50
|
+
a.click();
|
|
51
|
+
a.remove();
|
|
52
|
+
URL.revokeObjectURL(url);
|
|
53
|
+
}
|
|
54
|
+
onClose();
|
|
55
|
+
} catch {
|
|
56
|
+
// Stay open on error so user can retry
|
|
57
|
+
} finally {
|
|
58
|
+
setExporting(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
64
|
+
<div
|
|
65
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
66
|
+
onClick={onClose}
|
|
67
|
+
/>
|
|
68
|
+
<div className="relative bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-sm mx-4 border border-stone-200 dark:border-white/10">
|
|
69
|
+
<div className="p-6">
|
|
70
|
+
<h2 className="text-[18px] font-semibold text-primary mb-5">
|
|
71
|
+
Export to CSV
|
|
72
|
+
</h2>
|
|
73
|
+
|
|
74
|
+
<div className="space-y-4">
|
|
75
|
+
{/* Include headers */}
|
|
76
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
77
|
+
<input
|
|
78
|
+
type="checkbox"
|
|
79
|
+
checked={includeHeaders}
|
|
80
|
+
onChange={(e) => setIncludeHeaders(e.target.checked)}
|
|
81
|
+
className="rounded border-stone-300 dark:border-white/20"
|
|
82
|
+
/>
|
|
83
|
+
<span className="text-[14px] text-primary">
|
|
84
|
+
Include column headers
|
|
85
|
+
</span>
|
|
86
|
+
</label>
|
|
87
|
+
|
|
88
|
+
{/* Data scope - only when fetchAllRows is available */}
|
|
89
|
+
{fetchAllRows && (
|
|
90
|
+
<div>
|
|
91
|
+
<p className="text-[13px] text-secondary mb-2">Data scope</p>
|
|
92
|
+
<div className="space-y-1.5">
|
|
93
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
94
|
+
<input
|
|
95
|
+
type="radio"
|
|
96
|
+
name="scope"
|
|
97
|
+
checked={scope === "current"}
|
|
98
|
+
onChange={() => setScope("current")}
|
|
99
|
+
/>
|
|
100
|
+
<span className="text-[14px] text-primary">
|
|
101
|
+
Current page ({currentRows.length.toLocaleString()} rows)
|
|
102
|
+
</span>
|
|
103
|
+
</label>
|
|
104
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
105
|
+
<input
|
|
106
|
+
type="radio"
|
|
107
|
+
name="scope"
|
|
108
|
+
checked={scope === "all"}
|
|
109
|
+
onChange={() => setScope("all")}
|
|
110
|
+
/>
|
|
111
|
+
<span className="text-[14px] text-primary">
|
|
112
|
+
All rows
|
|
113
|
+
{totalRowCount != null &&
|
|
114
|
+
` (${totalRowCount.toLocaleString()} total)`}
|
|
115
|
+
</span>
|
|
116
|
+
</label>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="flex gap-3 pt-5">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => doExport("clipboard")}
|
|
126
|
+
disabled={exporting}
|
|
127
|
+
className="flex-1 px-4 py-2.5 text-[14px] font-medium text-secondary bg-stone-100 dark:bg-white/5 hover:bg-stone-200 dark:hover:bg-white/10 disabled:opacity-50 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
128
|
+
>
|
|
129
|
+
{exporting ? "Exporting..." : "Copy to clipboard"}
|
|
130
|
+
</button>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => doExport("file")}
|
|
134
|
+
disabled={exporting}
|
|
135
|
+
className="flex-1 px-4 py-2.5 text-[14px] font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
136
|
+
>
|
|
137
|
+
{exporting ? "Exporting..." : "Save to file"}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|