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,274 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { DatabaseConfig } from "../types";
|
|
3
|
+
import { useHotkey } from "../stores/hooks";
|
|
4
|
+
|
|
5
|
+
const COLORS = [
|
|
6
|
+
"#ef4444", // red
|
|
7
|
+
"#f59e0b", // amber
|
|
8
|
+
"#22c55e", // green
|
|
9
|
+
"#3b82f6", // blue
|
|
10
|
+
"#8b5cf6", // violet
|
|
11
|
+
"#ec4899", // pink
|
|
12
|
+
"#14b8a6", // teal
|
|
13
|
+
"#f97316", // orange
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
interface NewConnectionModalProps {
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
onSave: (config: DatabaseConfig) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function NewConnectionModal({
|
|
22
|
+
onClose,
|
|
23
|
+
onSave,
|
|
24
|
+
}: NewConnectionModalProps) {
|
|
25
|
+
const [connectionString, setConnectionString] = useState("");
|
|
26
|
+
const [name, setName] = useState("");
|
|
27
|
+
const [color, setColor] = useState(COLORS[0]);
|
|
28
|
+
const [host, setHost] = useState("localhost");
|
|
29
|
+
const [port, setPort] = useState("5432");
|
|
30
|
+
const [database, setDatabase] = useState("");
|
|
31
|
+
const [username, setUsername] = useState("postgres");
|
|
32
|
+
const [password, setPassword] = useState("");
|
|
33
|
+
const [extraParams, setExtraParams] = useState<Record<string, string>>({});
|
|
34
|
+
const [paramsString, setParamsString] = useState("");
|
|
35
|
+
|
|
36
|
+
useHotkey("closeModal", onClose);
|
|
37
|
+
|
|
38
|
+
function parseParamsString(value: string) {
|
|
39
|
+
const params: Record<string, string> = {};
|
|
40
|
+
if (value.trim()) {
|
|
41
|
+
for (const pair of value.split("&")) {
|
|
42
|
+
const [k, ...rest] = pair.split("=");
|
|
43
|
+
if (k?.trim()) params[k.trim()] = rest.join("=") || "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
setExtraParams(params);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseConnectionString(connStr: string) {
|
|
50
|
+
// Parse: postgresql://username:password@host:port/database
|
|
51
|
+
// or: postgres://username:password@host:port/database
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(connStr);
|
|
54
|
+
if (url.protocol === "postgresql:" || url.protocol === "postgres:") {
|
|
55
|
+
if (url.hostname) setHost(url.hostname);
|
|
56
|
+
if (url.port) setPort(url.port);
|
|
57
|
+
if (url.username) setUsername(decodeURIComponent(url.username));
|
|
58
|
+
if (url.password) setPassword(decodeURIComponent(url.password));
|
|
59
|
+
if (url.pathname && url.pathname.length > 1) {
|
|
60
|
+
setDatabase(url.pathname.slice(1)); // remove leading /
|
|
61
|
+
}
|
|
62
|
+
const params: Record<string, string> = {};
|
|
63
|
+
url.searchParams.forEach((value, key) => {
|
|
64
|
+
params[key] = value;
|
|
65
|
+
});
|
|
66
|
+
setExtraParams(params);
|
|
67
|
+
setParamsString(url.search ? url.searchParams.toString() : "");
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Not a valid URL, ignore
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleConnectionStringChange(value: string) {
|
|
75
|
+
setConnectionString(value);
|
|
76
|
+
parseConnectionString(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleSubmit(e: React.FormEvent) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
const config: DatabaseConfig = {
|
|
82
|
+
id: Date.now().toString(),
|
|
83
|
+
display: { name: name || "Untitled Connection", color },
|
|
84
|
+
connection: {
|
|
85
|
+
type: "postgres",
|
|
86
|
+
host,
|
|
87
|
+
port: parseInt(port, 10) || 5432,
|
|
88
|
+
database,
|
|
89
|
+
username,
|
|
90
|
+
password,
|
|
91
|
+
...(Object.keys(extraParams).length > 0 && { params: extraParams }),
|
|
92
|
+
},
|
|
93
|
+
cache: {},
|
|
94
|
+
source: "local",
|
|
95
|
+
};
|
|
96
|
+
onSave(config);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
101
|
+
<div className="flex min-h-full items-center justify-center p-4">
|
|
102
|
+
<div
|
|
103
|
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
|
104
|
+
onClick={onClose}
|
|
105
|
+
/>
|
|
106
|
+
<div className="relative bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-md border border-stone-200 dark:border-white/10">
|
|
107
|
+
<div className="p-6">
|
|
108
|
+
<h2 className="text-[18px] font-semibold text-primary mb-6">
|
|
109
|
+
New Connection
|
|
110
|
+
</h2>
|
|
111
|
+
|
|
112
|
+
<form
|
|
113
|
+
onSubmit={handleSubmit}
|
|
114
|
+
className="space-y-4"
|
|
115
|
+
autoComplete="off"
|
|
116
|
+
>
|
|
117
|
+
<div>
|
|
118
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
119
|
+
Connection String
|
|
120
|
+
</label>
|
|
121
|
+
<input
|
|
122
|
+
type="text"
|
|
123
|
+
value={connectionString}
|
|
124
|
+
onChange={(e) => handleConnectionStringChange(e.target.value)}
|
|
125
|
+
placeholder="postgresql://user:pass@host:5432/dbname"
|
|
126
|
+
className="w-full 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 font-mono"
|
|
127
|
+
autoFocus
|
|
128
|
+
/>
|
|
129
|
+
<p className="text-[11px] text-tertiary mt-1">
|
|
130
|
+
Paste a connection string to auto-fill the fields below
|
|
131
|
+
</p>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div className="border-t border-stone-200 dark:border-white/10 pt-4">
|
|
135
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
136
|
+
Connection Name
|
|
137
|
+
</label>
|
|
138
|
+
<input
|
|
139
|
+
type="text"
|
|
140
|
+
value={name}
|
|
141
|
+
onChange={(e) => setName(e.target.value)}
|
|
142
|
+
placeholder="My Database"
|
|
143
|
+
className="w-full 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"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div>
|
|
148
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
149
|
+
Color
|
|
150
|
+
</label>
|
|
151
|
+
<div className="flex gap-2">
|
|
152
|
+
{COLORS.map((c) => (
|
|
153
|
+
<button
|
|
154
|
+
key={c}
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => setColor(c)}
|
|
157
|
+
className={`w-7 h-7 rounded-full transition-transform ${
|
|
158
|
+
color === c
|
|
159
|
+
? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-[#1a1a1a] ring-blue-500 scale-110"
|
|
160
|
+
: "hover:scale-110"
|
|
161
|
+
}`}
|
|
162
|
+
style={{ backgroundColor: c }}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="grid grid-cols-3 gap-3">
|
|
169
|
+
<div className="col-span-2">
|
|
170
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
171
|
+
Host
|
|
172
|
+
</label>
|
|
173
|
+
<input
|
|
174
|
+
type="text"
|
|
175
|
+
value={host}
|
|
176
|
+
onChange={(e) => setHost(e.target.value)}
|
|
177
|
+
placeholder="localhost"
|
|
178
|
+
className="w-full 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 font-mono"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
<div>
|
|
182
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
183
|
+
Port
|
|
184
|
+
</label>
|
|
185
|
+
<input
|
|
186
|
+
type="text"
|
|
187
|
+
value={port}
|
|
188
|
+
onChange={(e) => setPort(e.target.value)}
|
|
189
|
+
placeholder="5432"
|
|
190
|
+
className="w-full 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 font-mono"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div>
|
|
196
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
197
|
+
Database
|
|
198
|
+
</label>
|
|
199
|
+
<input
|
|
200
|
+
type="text"
|
|
201
|
+
value={database}
|
|
202
|
+
onChange={(e) => setDatabase(e.target.value)}
|
|
203
|
+
placeholder="postgres"
|
|
204
|
+
className="w-full 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 font-mono"
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div className="grid grid-cols-2 gap-3">
|
|
209
|
+
<div>
|
|
210
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
211
|
+
Username
|
|
212
|
+
</label>
|
|
213
|
+
<input
|
|
214
|
+
type="text"
|
|
215
|
+
value={username}
|
|
216
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
217
|
+
placeholder="postgres"
|
|
218
|
+
autoComplete="off"
|
|
219
|
+
className="w-full 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 font-mono"
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
<div>
|
|
223
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
224
|
+
Password
|
|
225
|
+
</label>
|
|
226
|
+
<input
|
|
227
|
+
type="password"
|
|
228
|
+
value={password}
|
|
229
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
230
|
+
placeholder="••••••••"
|
|
231
|
+
autoComplete="new-password"
|
|
232
|
+
className="w-full 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 font-mono"
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div>
|
|
238
|
+
<label className="block text-[12px] font-medium text-secondary mb-1.5">
|
|
239
|
+
Parameters
|
|
240
|
+
</label>
|
|
241
|
+
<input
|
|
242
|
+
type="text"
|
|
243
|
+
value={paramsString}
|
|
244
|
+
onChange={(e) => {
|
|
245
|
+
setParamsString(e.target.value);
|
|
246
|
+
parseParamsString(e.target.value);
|
|
247
|
+
}}
|
|
248
|
+
placeholder="sslmode=require"
|
|
249
|
+
className="w-full 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 font-mono"
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="flex gap-3 pt-4">
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={onClose}
|
|
257
|
+
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 rounded-lg transition-colors"
|
|
258
|
+
>
|
|
259
|
+
Cancel
|
|
260
|
+
</button>
|
|
261
|
+
<button
|
|
262
|
+
type="submit"
|
|
263
|
+
className="flex-1 px-4 py-2.5 text-[14px] font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
|
264
|
+
>
|
|
265
|
+
Save Connection
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
</form>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface ResizerProps {
|
|
4
|
+
direction: "horizontal" | "vertical";
|
|
5
|
+
onResize: (delta: number) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Resizer({ direction, onResize }: ResizerProps) {
|
|
9
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
10
|
+
|
|
11
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
setIsDragging(true);
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!isDragging) return;
|
|
18
|
+
|
|
19
|
+
let lastPos = direction === "horizontal" ? 0 : 0;
|
|
20
|
+
|
|
21
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
22
|
+
const currentPos = direction === "horizontal" ? e.clientX : e.clientY;
|
|
23
|
+
if (lastPos !== 0) {
|
|
24
|
+
const delta = currentPos - lastPos;
|
|
25
|
+
onResize(delta);
|
|
26
|
+
}
|
|
27
|
+
lastPos = currentPos;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const handleMouseUp = () => {
|
|
31
|
+
setIsDragging(false);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
document.addEventListener("mousemove", handleMouseMove);
|
|
35
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
document.removeEventListener("mousemove", handleMouseMove);
|
|
39
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
40
|
+
};
|
|
41
|
+
}, [isDragging, direction, onResize]);
|
|
42
|
+
|
|
43
|
+
const isHorizontal = direction === "horizontal";
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
onMouseDown={handleMouseDown}
|
|
48
|
+
className={`
|
|
49
|
+
${isHorizontal ? "w-1 cursor-col-resize" : "h-1 cursor-row-resize"}
|
|
50
|
+
flex-shrink-0 bg-transparent hover:bg-blue-500/50 active:bg-blue-500/50 transition-colors
|
|
51
|
+
${isDragging ? "bg-blue-500/50" : ""}
|
|
52
|
+
`}
|
|
53
|
+
style={{
|
|
54
|
+
// Expand hit area
|
|
55
|
+
...(isHorizontal
|
|
56
|
+
? { marginLeft: -4, marginRight: -4, paddingLeft: 4, paddingRight: 4 }
|
|
57
|
+
: {
|
|
58
|
+
marginTop: -4,
|
|
59
|
+
marginBottom: -4,
|
|
60
|
+
paddingTop: 4,
|
|
61
|
+
paddingBottom: 4,
|
|
62
|
+
}),
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useHotkey } from "../stores/hooks";
|
|
3
|
+
|
|
4
|
+
interface ScanSuccessModalProps {
|
|
5
|
+
count: number;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CONFETTI_COLORS = [
|
|
10
|
+
"#ef4444",
|
|
11
|
+
"#f59e0b",
|
|
12
|
+
"#22c55e",
|
|
13
|
+
"#3b82f6",
|
|
14
|
+
"#8b5cf6",
|
|
15
|
+
"#ec4899",
|
|
16
|
+
"#14b8a6",
|
|
17
|
+
"#f97316",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function ConfettiPiece({ index }: { index: number }) {
|
|
21
|
+
const [style] = useState(() => {
|
|
22
|
+
const color =
|
|
23
|
+
CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
|
24
|
+
const left = Math.random() * 100;
|
|
25
|
+
const delay = Math.random() * 0.5;
|
|
26
|
+
const duration = 1.5 + Math.random() * 1.5;
|
|
27
|
+
const size = 6 + Math.random() * 6;
|
|
28
|
+
const rotation = Math.random() * 360;
|
|
29
|
+
const isCircle = index % 3 === 0;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
left: `${left}%`,
|
|
33
|
+
width: `${size}px`,
|
|
34
|
+
height: `${size}px`,
|
|
35
|
+
backgroundColor: color,
|
|
36
|
+
borderRadius: isCircle ? "50%" : "2px",
|
|
37
|
+
animationDelay: `${delay}s`,
|
|
38
|
+
animationDuration: `${duration}s`,
|
|
39
|
+
transform: `rotate(${rotation}deg)`,
|
|
40
|
+
} as React.CSSProperties;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return <div className="confetti-piece" style={style} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ScanSuccessModal({ count, onClose }: ScanSuccessModalProps) {
|
|
47
|
+
useHotkey("closeModal", onClose);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const timer = setTimeout(onClose, 4000);
|
|
51
|
+
return () => clearTimeout(timer);
|
|
52
|
+
}, [onClose]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
56
|
+
<div
|
|
57
|
+
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
58
|
+
onClick={onClose}
|
|
59
|
+
/>
|
|
60
|
+
<div className="confetti-container">
|
|
61
|
+
{Array.from({ length: 40 }, (_, i) => (
|
|
62
|
+
<ConfettiPiece key={i} index={i} />
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
<div className="relative z-[2] bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-sm mx-4 border border-stone-200 dark:border-white/10">
|
|
66
|
+
<div className="p-8 text-center">
|
|
67
|
+
<div className="text-5xl mb-4">🎉</div>
|
|
68
|
+
<h2 className="text-[20px] font-semibold text-primary mb-2">
|
|
69
|
+
Congrats!
|
|
70
|
+
</h2>
|
|
71
|
+
<p className="text-[14px] text-secondary mb-6">
|
|
72
|
+
{count} new database{count !== 1 ? "s" : ""} found on localhost
|
|
73
|
+
</p>
|
|
74
|
+
<button
|
|
75
|
+
onClick={onClose}
|
|
76
|
+
className="px-6 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"
|
|
77
|
+
>
|
|
78
|
+
Okay
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<style>{`
|
|
84
|
+
.confetti-container {
|
|
85
|
+
position: fixed;
|
|
86
|
+
inset: 0;
|
|
87
|
+
pointer-events: none;
|
|
88
|
+
overflow: hidden;
|
|
89
|
+
z-index: 1;
|
|
90
|
+
}
|
|
91
|
+
.confetti-piece {
|
|
92
|
+
position: absolute;
|
|
93
|
+
top: -10px;
|
|
94
|
+
opacity: 0;
|
|
95
|
+
animation: confetti-fall linear forwards;
|
|
96
|
+
}
|
|
97
|
+
@keyframes confetti-fall {
|
|
98
|
+
0% {
|
|
99
|
+
opacity: 1;
|
|
100
|
+
transform: translateY(0) rotate(0deg);
|
|
101
|
+
}
|
|
102
|
+
80% {
|
|
103
|
+
opacity: 1;
|
|
104
|
+
}
|
|
105
|
+
100% {
|
|
106
|
+
opacity: 0;
|
|
107
|
+
transform: translateY(100vh) rotate(720deg);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
`}</style>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|