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.
Files changed (69) hide show
  1. package/README.md +73 -0
  2. package/bin/cli.js +83 -0
  3. package/bin/install-local.js +57 -0
  4. package/electron/generate-icon.mjs +54 -0
  5. package/electron/icon.icns +0 -0
  6. package/electron/icon.png +0 -0
  7. package/electron/icon.svg +21 -0
  8. package/electron/main.js +169 -0
  9. package/electron/patch-dev-plist.js +31 -0
  10. package/electron/preload.cjs +18 -0
  11. package/electron/wait-for-vite.js +43 -0
  12. package/index.html +13 -0
  13. package/package.json +91 -0
  14. package/public/favicon.svg +15 -0
  15. package/public/vite.svg +1 -0
  16. package/server/export.ts +57 -0
  17. package/server/index.ts +392 -0
  18. package/src/App.css +1 -0
  19. package/src/App.tsx +543 -0
  20. package/src/assets/react.svg +1 -0
  21. package/src/components/CommandPalette.tsx +243 -0
  22. package/src/components/ConnectedView.tsx +78 -0
  23. package/src/components/ConnectionPicker.tsx +381 -0
  24. package/src/components/ConsoleView.tsx +360 -0
  25. package/src/components/CsvExportModal.tsx +144 -0
  26. package/src/components/DataGrid/DataGrid.tsx +262 -0
  27. package/src/components/DataGrid/DataGridCell.tsx +73 -0
  28. package/src/components/DataGrid/DataGridHeader.tsx +89 -0
  29. package/src/components/DataGrid/index.ts +20 -0
  30. package/src/components/DataGrid/types.ts +63 -0
  31. package/src/components/DataGrid/useColumnResize.ts +153 -0
  32. package/src/components/DataGrid/useDataGridSelection.ts +340 -0
  33. package/src/components/DataGrid/utils.ts +184 -0
  34. package/src/components/DatabaseMenu.tsx +93 -0
  35. package/src/components/DatabaseSwitcher.tsx +208 -0
  36. package/src/components/DiffView.tsx +215 -0
  37. package/src/components/EditConnectionModal.tsx +417 -0
  38. package/src/components/ErrorBoundary.tsx +69 -0
  39. package/src/components/GlobalShortcuts.tsx +201 -0
  40. package/src/components/InnerTabBar.tsx +129 -0
  41. package/src/components/JsonTreeViewer.tsx +387 -0
  42. package/src/components/MemberAccessEditor.tsx +443 -0
  43. package/src/components/MembersModal.tsx +446 -0
  44. package/src/components/NewConnectionModal.tsx +274 -0
  45. package/src/components/Resizer.tsx +66 -0
  46. package/src/components/ScanSuccessModal.tsx +113 -0
  47. package/src/components/ShortcutSettingsModal.tsx +318 -0
  48. package/src/components/Sidebar.tsx +532 -0
  49. package/src/components/TabBar.tsx +188 -0
  50. package/src/components/TableView.tsx +2147 -0
  51. package/src/components/ThemeToggle.tsx +44 -0
  52. package/src/components/index.ts +17 -0
  53. package/src/constants.ts +12 -0
  54. package/src/electron.d.ts +12 -0
  55. package/src/index.css +44 -0
  56. package/src/main.tsx +13 -0
  57. package/src/stores/hooks.ts +1146 -0
  58. package/src/stores/index.ts +12 -0
  59. package/src/stores/store.ts +1514 -0
  60. package/src/stores/useCloudSync.ts +274 -0
  61. package/src/stores/useSyncDatabase.ts +422 -0
  62. package/src/types.ts +277 -0
  63. package/src/utils/csv.ts +27 -0
  64. package/src/vite-env.d.ts +2 -0
  65. package/tsconfig.app.json +28 -0
  66. package/tsconfig.json +7 -0
  67. package/tsconfig.node.json +26 -0
  68. package/tsconfig.server.json +14 -0
  69. package/vite.config.ts +14 -0
@@ -0,0 +1,446 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import type { DatabaseConfig, ConnectionMember, AccessMap } from "../types";
3
+ import { useHotkey } from "../stores/hooks";
4
+ import { useStore } from "../stores";
5
+ import { CLOUD_URL } from "../constants";
6
+ import { MemberAccessEditor } from "./MemberAccessEditor";
7
+
8
+ interface MembersModalProps {
9
+ config: DatabaseConfig;
10
+ onClose: () => void;
11
+ }
12
+
13
+ interface MembersResponse {
14
+ members: ConnectionMember[];
15
+ }
16
+
17
+ interface MemberResponse {
18
+ member: ConnectionMember;
19
+ }
20
+
21
+ interface ErrorResponse {
22
+ error: string;
23
+ }
24
+
25
+ function TrashIcon() {
26
+ return (
27
+ <svg
28
+ className="w-4 h-4"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ viewBox="0 0 24 24"
32
+ >
33
+ <path
34
+ strokeLinecap="round"
35
+ strokeLinejoin="round"
36
+ strokeWidth={2}
37
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
38
+ />
39
+ </svg>
40
+ );
41
+ }
42
+
43
+ function ChevronRightIcon() {
44
+ return (
45
+ <svg
46
+ className="w-4 h-4"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ viewBox="0 0 24 24"
50
+ >
51
+ <path
52
+ strokeLinecap="round"
53
+ strokeLinejoin="round"
54
+ strokeWidth={2}
55
+ d="M9 5l7 7-7 7"
56
+ />
57
+ </svg>
58
+ );
59
+ }
60
+
61
+ function Spinner() {
62
+ return (
63
+ <svg
64
+ className="animate-spin h-4 w-4"
65
+ xmlns="http://www.w3.org/2000/svg"
66
+ fill="none"
67
+ viewBox="0 0 24 24"
68
+ >
69
+ <circle
70
+ className="opacity-25"
71
+ cx="12"
72
+ cy="12"
73
+ r="10"
74
+ stroke="currentColor"
75
+ strokeWidth="4"
76
+ />
77
+ <path
78
+ className="opacity-75"
79
+ fill="currentColor"
80
+ 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"
81
+ />
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ type AccessPreset = "full" | "read-only" | "no-access";
87
+
88
+ const PRESET_MAP: Record<AccessPreset, AccessMap> = {
89
+ full: { "*": "write" },
90
+ "read-only": { "*": "read" },
91
+ "no-access": { "*": "none" },
92
+ };
93
+
94
+ function getAccessSummary(access: AccessMap): {
95
+ label: string;
96
+ color: string;
97
+ } {
98
+ const keys = Object.keys(access);
99
+ if (keys.length === 1 && access["*"] === "write") {
100
+ return {
101
+ label: "Full Access",
102
+ color:
103
+ "bg-green-100 dark:bg-green-500/20 text-green-600 dark:text-green-400",
104
+ };
105
+ }
106
+ if (keys.length === 1 && access["*"] === "read") {
107
+ return {
108
+ label: "Read Only",
109
+ color:
110
+ "bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400",
111
+ };
112
+ }
113
+ if (keys.length === 1 && access["*"] === "none") {
114
+ return {
115
+ label: "No Access",
116
+ color: "bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400",
117
+ };
118
+ }
119
+ return {
120
+ label: "Custom",
121
+ color: "bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400",
122
+ };
123
+ }
124
+
125
+ export function MembersModal({ config, onClose }: MembersModalProps) {
126
+ const cloudApiKey = useStore((s) => s.cloudApiKey);
127
+
128
+ const [members, setMembers] = useState<ConnectionMember[]>([]);
129
+ const [isLoading, setIsLoading] = useState(true);
130
+ const [error, setError] = useState<string | null>(null);
131
+
132
+ const [newEmail, setNewEmail] = useState("");
133
+ const [newAccessPreset, setNewAccessPreset] = useState<AccessPreset>("full");
134
+ const [isAdding, setIsAdding] = useState(false);
135
+ const [addError, setAddError] = useState<string | null>(null);
136
+
137
+ const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
138
+ const [isSavingAccess, setIsSavingAccess] = useState(false);
139
+ const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
140
+
141
+ useHotkey("closeModal", onClose);
142
+
143
+ const cloudId = config.cloud?.id;
144
+ const schemas = config.cache?.schemas ?? [];
145
+
146
+ const fetchMembers = useCallback(async () => {
147
+ if (!cloudApiKey || !cloudId) return;
148
+
149
+ setIsLoading(true);
150
+ setError(null);
151
+
152
+ try {
153
+ const response = await fetch(
154
+ `${CLOUD_URL}/api/connections/${cloudId}/members`,
155
+ {
156
+ headers: {
157
+ "x-api-key": cloudApiKey,
158
+ },
159
+ },
160
+ );
161
+
162
+ if (!response.ok) {
163
+ const data = (await response.json()) as ErrorResponse;
164
+ throw new Error(data.error || `HTTP ${response.status}`);
165
+ }
166
+
167
+ const data = (await response.json()) as MembersResponse;
168
+ setMembers(data.members);
169
+ } catch (err) {
170
+ setError(err instanceof Error ? err.message : "Failed to load members");
171
+ } finally {
172
+ setIsLoading(false);
173
+ }
174
+ }, [cloudApiKey, cloudId]);
175
+
176
+ useEffect(() => {
177
+ fetchMembers();
178
+ }, [fetchMembers]);
179
+
180
+ async function handleAddMember(e: React.FormEvent) {
181
+ e.preventDefault();
182
+ if (!cloudApiKey || !cloudId || !newEmail.trim()) return;
183
+
184
+ setIsAdding(true);
185
+ setAddError(null);
186
+
187
+ try {
188
+ const response = await fetch(
189
+ `${CLOUD_URL}/api/connections/${cloudId}/members`,
190
+ {
191
+ method: "POST",
192
+ headers: {
193
+ "Content-Type": "application/json",
194
+ "x-api-key": cloudApiKey,
195
+ },
196
+ body: JSON.stringify({
197
+ email: newEmail.trim(),
198
+ access: PRESET_MAP[newAccessPreset],
199
+ }),
200
+ },
201
+ );
202
+
203
+ if (!response.ok) {
204
+ const data = (await response.json()) as ErrorResponse;
205
+ throw new Error(data.error || `HTTP ${response.status}`);
206
+ }
207
+
208
+ const data = (await response.json()) as MemberResponse;
209
+ setMembers((prev) => [...prev, data.member]);
210
+ setNewEmail("");
211
+ setNewAccessPreset("full");
212
+ } catch (err) {
213
+ setAddError(err instanceof Error ? err.message : "Failed to add member");
214
+ } finally {
215
+ setIsAdding(false);
216
+ }
217
+ }
218
+
219
+ async function handleUpdateAccess(memberId: string, access: AccessMap) {
220
+ if (!cloudApiKey || !cloudId) return;
221
+
222
+ setIsSavingAccess(true);
223
+
224
+ try {
225
+ const response = await fetch(
226
+ `${CLOUD_URL}/api/connections/${cloudId}/members/${memberId}`,
227
+ {
228
+ method: "PUT",
229
+ headers: {
230
+ "Content-Type": "application/json",
231
+ "x-api-key": cloudApiKey,
232
+ },
233
+ body: JSON.stringify({ access }),
234
+ },
235
+ );
236
+
237
+ if (!response.ok) {
238
+ const data = (await response.json()) as ErrorResponse;
239
+ throw new Error(data.error || `HTTP ${response.status}`);
240
+ }
241
+
242
+ const data = (await response.json()) as MemberResponse;
243
+ setMembers((prev) =>
244
+ prev.map((m) => (m.id === memberId ? data.member : m)),
245
+ );
246
+ setSelectedMemberId(null);
247
+ } catch (err) {
248
+ setError(err instanceof Error ? err.message : "Failed to update member");
249
+ } finally {
250
+ setIsSavingAccess(false);
251
+ }
252
+ }
253
+
254
+ async function handleRemoveMember(memberId: string) {
255
+ if (!cloudApiKey || !cloudId) return;
256
+
257
+ setRemovingMemberId(memberId);
258
+
259
+ try {
260
+ const response = await fetch(
261
+ `${CLOUD_URL}/api/connections/${cloudId}/members/${memberId}`,
262
+ {
263
+ method: "DELETE",
264
+ headers: {
265
+ "x-api-key": cloudApiKey,
266
+ },
267
+ },
268
+ );
269
+
270
+ if (!response.ok) {
271
+ const data = (await response.json()) as ErrorResponse;
272
+ throw new Error(data.error || `HTTP ${response.status}`);
273
+ }
274
+
275
+ setMembers((prev) => prev.filter((m) => m.id !== memberId));
276
+ } catch (err) {
277
+ setError(err instanceof Error ? err.message : "Failed to remove member");
278
+ } finally {
279
+ setRemovingMemberId(null);
280
+ }
281
+ }
282
+
283
+ const selectedMember = selectedMemberId
284
+ ? members.find((m) => m.id === selectedMemberId)
285
+ : null;
286
+
287
+ return (
288
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
289
+ <div
290
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
291
+ onClick={onClose}
292
+ />
293
+ <div className="relative bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-lg mx-4 border border-stone-200 dark:border-white/10">
294
+ <div className="p-6">
295
+ {selectedMember ? (
296
+ /* Detail view - Access editor */
297
+ <MemberAccessEditor
298
+ member={selectedMember}
299
+ schemas={schemas}
300
+ onSave={(access) => handleUpdateAccess(selectedMember.id, access)}
301
+ onBack={() => setSelectedMemberId(null)}
302
+ isSaving={isSavingAccess}
303
+ />
304
+ ) : (
305
+ /* List view */
306
+ <>
307
+ <div className="flex items-center gap-3 mb-6">
308
+ <span
309
+ className="w-3 h-3 rounded-full flex-shrink-0"
310
+ style={{ backgroundColor: config.display.color }}
311
+ />
312
+ <h2 className="text-[18px] font-semibold text-primary">
313
+ Manage Members
314
+ </h2>
315
+ </div>
316
+
317
+ <p className="text-[13px] text-secondary mb-4">
318
+ Add members by email to grant them access to{" "}
319
+ <span className="font-medium text-primary">
320
+ {config.display.name}
321
+ </span>
322
+ . They'll get access when they sign in with that email.
323
+ </p>
324
+
325
+ {/* Error message */}
326
+ {error && (
327
+ <div className="mb-4 p-3 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-200 dark:border-red-500/20">
328
+ {error}
329
+ </div>
330
+ )}
331
+
332
+ {/* Members list */}
333
+ <div className="mb-6">
334
+ <label className="block text-[12px] font-medium text-secondary mb-2">
335
+ Members
336
+ </label>
337
+ {isLoading ? (
338
+ <div className="py-8 text-center text-[13px] text-tertiary">
339
+ Loading members...
340
+ </div>
341
+ ) : members.length === 0 ? (
342
+ <div className="py-8 text-center text-[13px] text-tertiary border border-dashed border-stone-200 dark:border-white/10 rounded-lg">
343
+ No members yet. Add someone below.
344
+ </div>
345
+ ) : (
346
+ <div className="space-y-2 max-h-64 overflow-y-auto">
347
+ {members.map((member) => {
348
+ const summary = getAccessSummary(member.access);
349
+ return (
350
+ <div
351
+ key={member.id}
352
+ className="flex items-center gap-3 p-3 bg-stone-50 dark:bg-white/[0.02] border border-stone-200 dark:border-white/[0.06] rounded-lg hover:bg-stone-100 dark:hover:bg-white/[0.04] cursor-pointer transition-colors"
353
+ onClick={() => setSelectedMemberId(member.id)}
354
+ >
355
+ <div className="flex-1 min-w-0">
356
+ <div className="text-[14px] text-primary truncate">
357
+ {member.email}
358
+ </div>
359
+ </div>
360
+ <span
361
+ className={`px-2 py-0.5 text-[11px] font-medium rounded ${summary.color}`}
362
+ >
363
+ {summary.label}
364
+ </span>
365
+ <button
366
+ onClick={(e) => {
367
+ e.stopPropagation();
368
+ handleRemoveMember(member.id);
369
+ }}
370
+ disabled={removingMemberId === member.id}
371
+ className="p-1.5 rounded text-tertiary hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors disabled:opacity-50"
372
+ title="Remove member"
373
+ >
374
+ {removingMemberId === member.id ? (
375
+ <Spinner />
376
+ ) : (
377
+ <TrashIcon />
378
+ )}
379
+ </button>
380
+ <span className="text-tertiary">
381
+ <ChevronRightIcon />
382
+ </span>
383
+ </div>
384
+ );
385
+ })}
386
+ </div>
387
+ )}
388
+ </div>
389
+
390
+ {/* Add member form */}
391
+ <form onSubmit={handleAddMember} className="space-y-3">
392
+ <label className="block text-[12px] font-medium text-secondary">
393
+ Add Member
394
+ </label>
395
+ <div className="flex gap-2">
396
+ <input
397
+ type="email"
398
+ value={newEmail}
399
+ onChange={(e) => setNewEmail(e.target.value)}
400
+ placeholder="email@example.com"
401
+ disabled={isAdding}
402
+ className="flex-1 px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50"
403
+ />
404
+ <select
405
+ value={newAccessPreset}
406
+ onChange={(e) =>
407
+ setNewAccessPreset(e.target.value as AccessPreset)
408
+ }
409
+ disabled={isAdding}
410
+ className="px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50"
411
+ >
412
+ <option value="full">Full Access</option>
413
+ <option value="read-only">Read Only</option>
414
+ <option value="no-access">No Access</option>
415
+ </select>
416
+ <button
417
+ type="submit"
418
+ disabled={isAdding || !newEmail.trim()}
419
+ className="px-4 py-2 text-[14px] font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
420
+ >
421
+ {isAdding && <Spinner />}
422
+ Add
423
+ </button>
424
+ </div>
425
+ {addError && (
426
+ <p className="text-[12px] text-red-500">{addError}</p>
427
+ )}
428
+ </form>
429
+
430
+ {/* Close button */}
431
+ <div className="flex justify-end mt-6 pt-4 border-t border-stone-200 dark:border-white/10">
432
+ <button
433
+ type="button"
434
+ onClick={onClose}
435
+ className="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 rounded-lg transition-colors"
436
+ >
437
+ Done
438
+ </button>
439
+ </div>
440
+ </>
441
+ )}
442
+ </div>
443
+ </div>
444
+ </div>
445
+ );
446
+ }