@tradejs/app 1.0.4 → 1.0.6
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 +1 -1
- package/bin/tradejs-app.mjs +122 -20
- package/package.json +18 -8
- package/public/auth-bg.jpg +0 -0
- package/public/next.svg +1 -0
- package/public/og-image-source.svg +91 -0
- package/public/og-image.png +0 -0
- package/public/vercel.svg +1 -0
- package/src/app/api/ai/route.ts +26 -5
- package/src/app/api/files/screenshot/[name]/route.ts +53 -17
- package/src/app/api/kline/[provider]/[symbol]/[interval]/route.ts +7 -1
- package/src/app/api/scanner/[provider]/route.ts +7 -1
- package/src/app/api/scanner/route.ts +7 -1
- package/src/app/api/user/settings/route.ts +216 -0
- package/src/app/components/Dashboard/MainChart/index.tsx +10 -2
- package/src/app/components/Shared/Sidebar/AccountSettingsDrawer.tsx +809 -0
- package/src/app/components/Shared/Sidebar/index.tsx +13 -9
- package/src/app/globals.css +11 -0
- package/src/app/layout.tsx +41 -4
- package/src/app/lib/currentUser.ts +27 -0
- package/src/app/routes/dashboard/[provider]/[symbol]/[interval]/page.tsx +22 -19
- package/src/app/routes/signin/page.tsx +11 -2
- package/src/app/store/data.ts +192 -87
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
CloseButton,
|
|
8
|
+
Drawer,
|
|
9
|
+
Field,
|
|
10
|
+
Flex,
|
|
11
|
+
HStack,
|
|
12
|
+
IconButton,
|
|
13
|
+
Input,
|
|
14
|
+
Portal,
|
|
15
|
+
Spinner,
|
|
16
|
+
Stack,
|
|
17
|
+
Text,
|
|
18
|
+
} from '@chakra-ui/react';
|
|
19
|
+
import { FiEdit2, FiSettings } from 'react-icons/fi';
|
|
20
|
+
import { toaster } from '@UI';
|
|
21
|
+
|
|
22
|
+
type SettingsResponse = {
|
|
23
|
+
userName: string;
|
|
24
|
+
settings: {
|
|
25
|
+
bybit: {
|
|
26
|
+
apiKey: string;
|
|
27
|
+
apiSecret: string;
|
|
28
|
+
};
|
|
29
|
+
token: string;
|
|
30
|
+
coinalyze: {
|
|
31
|
+
apiKey: string;
|
|
32
|
+
};
|
|
33
|
+
openai: {
|
|
34
|
+
apiKey: string;
|
|
35
|
+
apiEndpoint: string;
|
|
36
|
+
};
|
|
37
|
+
telegram: {
|
|
38
|
+
botToken: string;
|
|
39
|
+
chatId: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SettingsErrorResponse = {
|
|
45
|
+
error?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type SettingsViewState = {
|
|
49
|
+
userName: string;
|
|
50
|
+
bybitApiKey: string;
|
|
51
|
+
bybitApiSecret: string;
|
|
52
|
+
token: string;
|
|
53
|
+
coinalyzeApiKey: string;
|
|
54
|
+
openAIApiKey: string;
|
|
55
|
+
openAIApiEndpoint: string;
|
|
56
|
+
tgBotToken: string;
|
|
57
|
+
tgChatId: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type SettingsDraftState = Omit<SettingsViewState, 'userName'>;
|
|
61
|
+
|
|
62
|
+
type PasswordState = {
|
|
63
|
+
password: string;
|
|
64
|
+
confirmPassword: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type SectionName =
|
|
68
|
+
| 'bybit'
|
|
69
|
+
| 'password'
|
|
70
|
+
| 'token'
|
|
71
|
+
| 'coinalyze'
|
|
72
|
+
| 'openai'
|
|
73
|
+
| 'telegram';
|
|
74
|
+
type EditableField =
|
|
75
|
+
| 'bybitApiKey'
|
|
76
|
+
| 'bybitApiSecret'
|
|
77
|
+
| 'token'
|
|
78
|
+
| 'coinalyzeApiKey'
|
|
79
|
+
| 'openAIApiKey'
|
|
80
|
+
| 'openAIApiEndpoint'
|
|
81
|
+
| 'tgBotToken'
|
|
82
|
+
| 'tgChatId';
|
|
83
|
+
|
|
84
|
+
const EMPTY_SETTINGS: SettingsViewState = {
|
|
85
|
+
userName: '',
|
|
86
|
+
bybitApiKey: '',
|
|
87
|
+
bybitApiSecret: '',
|
|
88
|
+
token: '',
|
|
89
|
+
coinalyzeApiKey: '',
|
|
90
|
+
openAIApiKey: '',
|
|
91
|
+
openAIApiEndpoint: '',
|
|
92
|
+
tgBotToken: '',
|
|
93
|
+
tgChatId: '',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const EMPTY_DRAFTS: SettingsDraftState = {
|
|
97
|
+
bybitApiKey: '',
|
|
98
|
+
bybitApiSecret: '',
|
|
99
|
+
token: '',
|
|
100
|
+
coinalyzeApiKey: '',
|
|
101
|
+
openAIApiKey: '',
|
|
102
|
+
openAIApiEndpoint: '',
|
|
103
|
+
tgBotToken: '',
|
|
104
|
+
tgChatId: '',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const EMPTY_PASSWORDS: PasswordState = {
|
|
108
|
+
password: '',
|
|
109
|
+
confirmPassword: '',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const EMPTY_EDITING: Record<EditableField, boolean> = {
|
|
113
|
+
bybitApiKey: false,
|
|
114
|
+
bybitApiSecret: false,
|
|
115
|
+
token: false,
|
|
116
|
+
coinalyzeApiKey: false,
|
|
117
|
+
openAIApiKey: false,
|
|
118
|
+
openAIApiEndpoint: false,
|
|
119
|
+
tgBotToken: false,
|
|
120
|
+
tgChatId: false,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const SECTION_FIELDS: Record<
|
|
124
|
+
Exclude<SectionName, 'password'>,
|
|
125
|
+
EditableField[]
|
|
126
|
+
> = {
|
|
127
|
+
bybit: ['bybitApiKey', 'bybitApiSecret'],
|
|
128
|
+
token: ['token'],
|
|
129
|
+
coinalyze: ['coinalyzeApiKey'],
|
|
130
|
+
openai: ['openAIApiKey', 'openAIApiEndpoint'],
|
|
131
|
+
telegram: ['tgBotToken', 'tgChatId'],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const MASKED_FIELDS = new Set<EditableField>([
|
|
135
|
+
'bybitApiKey',
|
|
136
|
+
'bybitApiSecret',
|
|
137
|
+
'token',
|
|
138
|
+
'coinalyzeApiKey',
|
|
139
|
+
'openAIApiKey',
|
|
140
|
+
'tgBotToken',
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const toViewState = (payload: SettingsResponse): SettingsViewState => ({
|
|
144
|
+
userName: payload.userName,
|
|
145
|
+
bybitApiKey: payload.settings.bybit.apiKey || '',
|
|
146
|
+
bybitApiSecret: payload.settings.bybit.apiSecret || '',
|
|
147
|
+
token: payload.settings.token || '',
|
|
148
|
+
coinalyzeApiKey: payload.settings.coinalyze.apiKey || '',
|
|
149
|
+
openAIApiKey: payload.settings.openai.apiKey || '',
|
|
150
|
+
openAIApiEndpoint: payload.settings.openai.apiEndpoint || '',
|
|
151
|
+
tgBotToken: payload.settings.telegram.botToken || '',
|
|
152
|
+
tgChatId: payload.settings.telegram.chatId || '',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const toDraftState = (view: SettingsViewState): SettingsDraftState => ({
|
|
156
|
+
...EMPTY_DRAFTS,
|
|
157
|
+
openAIApiEndpoint: view.openAIApiEndpoint,
|
|
158
|
+
tgChatId: view.tgChatId,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const isSettingsResponse = (
|
|
162
|
+
payload: SettingsResponse | SettingsErrorResponse,
|
|
163
|
+
): payload is SettingsResponse =>
|
|
164
|
+
Boolean(payload && typeof payload === 'object' && 'settings' in payload);
|
|
165
|
+
|
|
166
|
+
const getErrorMessage = (
|
|
167
|
+
payload: SettingsResponse | SettingsErrorResponse,
|
|
168
|
+
fallback: string,
|
|
169
|
+
) =>
|
|
170
|
+
'error' in payload && typeof payload.error === 'string' && payload.error
|
|
171
|
+
? payload.error
|
|
172
|
+
: fallback;
|
|
173
|
+
|
|
174
|
+
export const AccountSettingsDrawer = () => {
|
|
175
|
+
const [open, setOpen] = useState(false);
|
|
176
|
+
const [loading, setLoading] = useState(false);
|
|
177
|
+
const [settings, setSettings] = useState<SettingsViewState>(EMPTY_SETTINGS);
|
|
178
|
+
const [drafts, setDrafts] = useState<SettingsDraftState>(EMPTY_DRAFTS);
|
|
179
|
+
const [passwords, setPasswords] = useState<PasswordState>(EMPTY_PASSWORDS);
|
|
180
|
+
const [editing, setEditing] =
|
|
181
|
+
useState<Record<EditableField, boolean>>(EMPTY_EDITING);
|
|
182
|
+
const [savingSection, setSavingSection] = useState<SectionName | null>(null);
|
|
183
|
+
|
|
184
|
+
const syncState = useCallback((payload: SettingsResponse) => {
|
|
185
|
+
const next = toViewState(payload);
|
|
186
|
+
setSettings(next);
|
|
187
|
+
setDrafts(toDraftState(next));
|
|
188
|
+
setEditing(EMPTY_EDITING);
|
|
189
|
+
}, []);
|
|
190
|
+
|
|
191
|
+
const loadSettings = useCallback(async () => {
|
|
192
|
+
setLoading(true);
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetch('/api/user/settings', {
|
|
196
|
+
cache: 'no-store',
|
|
197
|
+
});
|
|
198
|
+
const payload = (await response.json()) as
|
|
199
|
+
| SettingsResponse
|
|
200
|
+
| SettingsErrorResponse;
|
|
201
|
+
|
|
202
|
+
if (!response.ok || !isSettingsResponse(payload)) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
getErrorMessage(payload, 'Failed to load account settings'),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
syncState(payload);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
toaster.error({
|
|
211
|
+
title: 'Failed to load settings',
|
|
212
|
+
description: (error as Error).message,
|
|
213
|
+
});
|
|
214
|
+
} finally {
|
|
215
|
+
setLoading(false);
|
|
216
|
+
}
|
|
217
|
+
}, [syncState]);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!open) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
void loadSettings();
|
|
225
|
+
}, [open, loadSettings]);
|
|
226
|
+
|
|
227
|
+
const passwordError = useMemo(() => {
|
|
228
|
+
if (!passwords.password && !passwords.confirmPassword) {
|
|
229
|
+
return '';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!passwords.password) {
|
|
233
|
+
return 'Password is required';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (passwords.password !== passwords.confirmPassword) {
|
|
237
|
+
return 'Passwords do not match';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return '';
|
|
241
|
+
}, [passwords.confirmPassword, passwords.password]);
|
|
242
|
+
|
|
243
|
+
const isSectionDirty = useCallback(
|
|
244
|
+
(section: SectionName) => {
|
|
245
|
+
if (section === 'password') {
|
|
246
|
+
return Boolean(passwords.password || passwords.confirmPassword);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (section === 'openai') {
|
|
250
|
+
return (
|
|
251
|
+
Boolean(drafts.openAIApiKey.trim()) ||
|
|
252
|
+
drafts.openAIApiEndpoint !== settings.openAIApiEndpoint
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (section === 'coinalyze') {
|
|
257
|
+
return Boolean(drafts.coinalyzeApiKey.trim());
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (section === 'telegram') {
|
|
261
|
+
return (
|
|
262
|
+
Boolean(drafts.tgBotToken.trim()) ||
|
|
263
|
+
drafts.tgChatId !== settings.tgChatId
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return SECTION_FIELDS[section].some((field) =>
|
|
268
|
+
Boolean(drafts[field].trim()),
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
[drafts, passwords.confirmPassword, passwords.password, settings],
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const updateDraft = (field: EditableField, value: string) => {
|
|
275
|
+
setDrafts((current) => ({
|
|
276
|
+
...current,
|
|
277
|
+
[field]: value,
|
|
278
|
+
}));
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const resetFieldDraft = useCallback(
|
|
282
|
+
(field: EditableField) => {
|
|
283
|
+
setDrafts((current) => ({
|
|
284
|
+
...current,
|
|
285
|
+
[field]: MASKED_FIELDS.has(field) ? '' : settings[field],
|
|
286
|
+
}));
|
|
287
|
+
},
|
|
288
|
+
[settings],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const cancelEditing = useCallback(
|
|
292
|
+
(field: EditableField) => {
|
|
293
|
+
setEditing((current) => ({
|
|
294
|
+
...current,
|
|
295
|
+
[field]: false,
|
|
296
|
+
}));
|
|
297
|
+
resetFieldDraft(field);
|
|
298
|
+
},
|
|
299
|
+
[resetFieldDraft],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const enableEditing = useCallback(
|
|
303
|
+
(field: EditableField) => {
|
|
304
|
+
setEditing((current) => ({
|
|
305
|
+
...current,
|
|
306
|
+
[field]: true,
|
|
307
|
+
}));
|
|
308
|
+
setDrafts((current) => ({
|
|
309
|
+
...current,
|
|
310
|
+
[field]: MASKED_FIELDS.has(field) ? '' : settings[field],
|
|
311
|
+
}));
|
|
312
|
+
},
|
|
313
|
+
[settings],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const getSecretUpdateValue = (field: EditableField) => {
|
|
317
|
+
const trimmed = drafts[field].trim();
|
|
318
|
+
return trimmed ? trimmed : undefined;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleFieldBlur = useCallback(
|
|
322
|
+
(field: EditableField, value: string) => {
|
|
323
|
+
if (MASKED_FIELDS.has(field)) {
|
|
324
|
+
if (!value.trim()) {
|
|
325
|
+
cancelEditing(field);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (value === settings[field]) {
|
|
332
|
+
cancelEditing(field);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
[cancelEditing, settings],
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const saveSection = async (section: SectionName) => {
|
|
339
|
+
if (section !== 'password' && !isSectionDirty(section)) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (section === 'password' && passwordError) {
|
|
344
|
+
toaster.error({
|
|
345
|
+
title: 'Password update failed',
|
|
346
|
+
description: passwordError,
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
setSavingSection(section);
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const body =
|
|
355
|
+
section === 'bybit'
|
|
356
|
+
? {
|
|
357
|
+
section,
|
|
358
|
+
data: {
|
|
359
|
+
apiKey: getSecretUpdateValue('bybitApiKey'),
|
|
360
|
+
apiSecret: getSecretUpdateValue('bybitApiSecret'),
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
: section === 'token'
|
|
364
|
+
? {
|
|
365
|
+
section,
|
|
366
|
+
data: {
|
|
367
|
+
token: getSecretUpdateValue('token'),
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
: section === 'coinalyze'
|
|
371
|
+
? {
|
|
372
|
+
section,
|
|
373
|
+
data: {
|
|
374
|
+
apiKey: getSecretUpdateValue('coinalyzeApiKey'),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
: section === 'openai'
|
|
378
|
+
? {
|
|
379
|
+
section,
|
|
380
|
+
data: {
|
|
381
|
+
apiKey: getSecretUpdateValue('openAIApiKey'),
|
|
382
|
+
apiEndpoint: getSecretUpdateValue('openAIApiEndpoint'),
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
: section === 'telegram'
|
|
386
|
+
? {
|
|
387
|
+
section,
|
|
388
|
+
data: {
|
|
389
|
+
botToken: getSecretUpdateValue('tgBotToken'),
|
|
390
|
+
chatId:
|
|
391
|
+
drafts.tgChatId !== settings.tgChatId
|
|
392
|
+
? drafts.tgChatId.trim()
|
|
393
|
+
: undefined,
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
: {
|
|
397
|
+
section,
|
|
398
|
+
data: {
|
|
399
|
+
password: passwords.password,
|
|
400
|
+
confirmPassword: passwords.confirmPassword,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const response = await fetch('/api/user/settings', {
|
|
405
|
+
method: 'PATCH',
|
|
406
|
+
headers: {
|
|
407
|
+
'Content-Type': 'application/json',
|
|
408
|
+
},
|
|
409
|
+
body: JSON.stringify(body),
|
|
410
|
+
});
|
|
411
|
+
const payload = (await response.json()) as
|
|
412
|
+
| SettingsResponse
|
|
413
|
+
| SettingsErrorResponse;
|
|
414
|
+
|
|
415
|
+
if (!response.ok || !isSettingsResponse(payload)) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
getErrorMessage(payload, 'Failed to save account settings'),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
syncState(payload);
|
|
422
|
+
|
|
423
|
+
if (section === 'password') {
|
|
424
|
+
setPasswords(EMPTY_PASSWORDS);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
toaster.success({
|
|
428
|
+
title: 'Settings saved',
|
|
429
|
+
description:
|
|
430
|
+
section === 'password'
|
|
431
|
+
? 'Password updated successfully.'
|
|
432
|
+
: 'Account settings updated successfully.',
|
|
433
|
+
});
|
|
434
|
+
} catch (error) {
|
|
435
|
+
toaster.error({
|
|
436
|
+
title: 'Save failed',
|
|
437
|
+
description: (error as Error).message,
|
|
438
|
+
});
|
|
439
|
+
} finally {
|
|
440
|
+
setSavingSection(null);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const renderEditableField = ({
|
|
445
|
+
label,
|
|
446
|
+
field,
|
|
447
|
+
placeholder,
|
|
448
|
+
}: {
|
|
449
|
+
label: string;
|
|
450
|
+
field: EditableField;
|
|
451
|
+
placeholder?: string;
|
|
452
|
+
}) => {
|
|
453
|
+
const isEditing = editing[field];
|
|
454
|
+
const savedValue = settings[field];
|
|
455
|
+
const isMasked = MASKED_FIELDS.has(field);
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<Field.Root key={field} width="full">
|
|
459
|
+
<Field.Label>{label}</Field.Label>
|
|
460
|
+
<HStack align="stretch" width="full">
|
|
461
|
+
{isEditing ? (
|
|
462
|
+
<Input
|
|
463
|
+
flex="1"
|
|
464
|
+
minW="0"
|
|
465
|
+
value={drafts[field]}
|
|
466
|
+
placeholder={placeholder}
|
|
467
|
+
onChange={(event) => updateDraft(field, event.target.value)}
|
|
468
|
+
onBlur={(event) => handleFieldBlur(field, event.target.value)}
|
|
469
|
+
onKeyDown={(event) => {
|
|
470
|
+
if (event.key === 'Escape') {
|
|
471
|
+
event.preventDefault();
|
|
472
|
+
cancelEditing(field);
|
|
473
|
+
}
|
|
474
|
+
}}
|
|
475
|
+
autoFocus
|
|
476
|
+
fontFamily={isMasked ? 'mono' : undefined}
|
|
477
|
+
fontVariantNumeric="tabular-nums"
|
|
478
|
+
/>
|
|
479
|
+
) : (
|
|
480
|
+
<Flex
|
|
481
|
+
flex="1"
|
|
482
|
+
minW="0"
|
|
483
|
+
h="10"
|
|
484
|
+
px="3"
|
|
485
|
+
borderWidth="1px"
|
|
486
|
+
borderColor="whiteAlpha.200"
|
|
487
|
+
borderRadius="md"
|
|
488
|
+
bg="blackAlpha.300"
|
|
489
|
+
align="center"
|
|
490
|
+
color={savedValue ? 'gray.200' : 'gray.500'}
|
|
491
|
+
cursor="default"
|
|
492
|
+
userSelect="none"
|
|
493
|
+
>
|
|
494
|
+
<Text
|
|
495
|
+
width="full"
|
|
496
|
+
overflow="hidden"
|
|
497
|
+
whiteSpace="nowrap"
|
|
498
|
+
textOverflow="ellipsis"
|
|
499
|
+
color={savedValue ? 'gray.200' : 'gray.500'}
|
|
500
|
+
fontFamily={savedValue ? 'mono' : undefined}
|
|
501
|
+
fontVariantNumeric="tabular-nums"
|
|
502
|
+
>
|
|
503
|
+
{savedValue || 'Not set'}
|
|
504
|
+
</Text>
|
|
505
|
+
</Flex>
|
|
506
|
+
)}
|
|
507
|
+
<IconButton
|
|
508
|
+
aria-label={`Edit ${label}`}
|
|
509
|
+
size="md"
|
|
510
|
+
colorPalette="teal"
|
|
511
|
+
variant={isEditing ? 'solid' : 'outline'}
|
|
512
|
+
flexShrink={0}
|
|
513
|
+
onClick={() => enableEditing(field)}
|
|
514
|
+
>
|
|
515
|
+
<FiEdit2 />
|
|
516
|
+
</IconButton>
|
|
517
|
+
</HStack>
|
|
518
|
+
</Field.Root>
|
|
519
|
+
);
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<Drawer.Root
|
|
524
|
+
open={open}
|
|
525
|
+
onOpenChange={(event) => setOpen(event.open)}
|
|
526
|
+
size="xl"
|
|
527
|
+
>
|
|
528
|
+
<Drawer.Trigger asChild>
|
|
529
|
+
<IconButton
|
|
530
|
+
aria-label="Account settings"
|
|
531
|
+
size="md"
|
|
532
|
+
colorPalette="teal"
|
|
533
|
+
variant="outline"
|
|
534
|
+
>
|
|
535
|
+
<FiSettings />
|
|
536
|
+
</IconButton>
|
|
537
|
+
</Drawer.Trigger>
|
|
538
|
+
<Portal>
|
|
539
|
+
<Drawer.Backdrop />
|
|
540
|
+
<Drawer.Positioner>
|
|
541
|
+
<Drawer.Content display="flex" flexDirection="column">
|
|
542
|
+
<Drawer.Header>
|
|
543
|
+
<Drawer.Title>Account settings</Drawer.Title>
|
|
544
|
+
<Drawer.CloseTrigger asChild>
|
|
545
|
+
<CloseButton position="absolute" right="3" top="3" />
|
|
546
|
+
</Drawer.CloseTrigger>
|
|
547
|
+
</Drawer.Header>
|
|
548
|
+
|
|
549
|
+
<Drawer.Body overflowY="auto">
|
|
550
|
+
{loading ? (
|
|
551
|
+
<Flex minH="320px" align="center" justify="center">
|
|
552
|
+
<Spinner color="teal.300" size="lg" />
|
|
553
|
+
</Flex>
|
|
554
|
+
) : (
|
|
555
|
+
<Stack gap={5}>
|
|
556
|
+
<Box>
|
|
557
|
+
<Text fontSize="sm" color="gray.400">
|
|
558
|
+
Signed in as
|
|
559
|
+
</Text>
|
|
560
|
+
<Text fontSize="lg" fontWeight="600">
|
|
561
|
+
{settings.userName || 'Unknown user'}
|
|
562
|
+
</Text>
|
|
563
|
+
</Box>
|
|
564
|
+
|
|
565
|
+
<Box
|
|
566
|
+
borderWidth="1px"
|
|
567
|
+
borderColor="gray.700"
|
|
568
|
+
borderRadius="lg"
|
|
569
|
+
p={4}
|
|
570
|
+
bg="gray.900"
|
|
571
|
+
>
|
|
572
|
+
<Stack gap={4}>
|
|
573
|
+
<Box>
|
|
574
|
+
<Text fontWeight="600">Bybit connection</Text>
|
|
575
|
+
<Text fontSize="sm" color="gray.400">
|
|
576
|
+
API credentials used for exchange access.
|
|
577
|
+
</Text>
|
|
578
|
+
</Box>
|
|
579
|
+
{renderEditableField({
|
|
580
|
+
label: 'BYBIT_API_KEY',
|
|
581
|
+
field: 'bybitApiKey',
|
|
582
|
+
placeholder: 'Enter a new Bybit API key',
|
|
583
|
+
})}
|
|
584
|
+
{renderEditableField({
|
|
585
|
+
label: 'BYBIT_API_SECRET',
|
|
586
|
+
field: 'bybitApiSecret',
|
|
587
|
+
placeholder: 'Enter a new Bybit API secret',
|
|
588
|
+
})}
|
|
589
|
+
<Flex justify="flex-end">
|
|
590
|
+
<Button
|
|
591
|
+
colorPalette="teal"
|
|
592
|
+
loading={savingSection === 'bybit'}
|
|
593
|
+
disabled={!isSectionDirty('bybit')}
|
|
594
|
+
onClick={() => saveSection('bybit')}
|
|
595
|
+
>
|
|
596
|
+
Save
|
|
597
|
+
</Button>
|
|
598
|
+
</Flex>
|
|
599
|
+
</Stack>
|
|
600
|
+
</Box>
|
|
601
|
+
|
|
602
|
+
<Box
|
|
603
|
+
borderWidth="1px"
|
|
604
|
+
borderColor="gray.700"
|
|
605
|
+
borderRadius="lg"
|
|
606
|
+
p={4}
|
|
607
|
+
bg="gray.900"
|
|
608
|
+
>
|
|
609
|
+
<Stack gap={4}>
|
|
610
|
+
<Box>
|
|
611
|
+
<Text fontWeight="600">OpenAI / LLM</Text>
|
|
612
|
+
<Text fontSize="sm" color="gray.400">
|
|
613
|
+
Stored in the user profile and used for AI analysis.
|
|
614
|
+
</Text>
|
|
615
|
+
</Box>
|
|
616
|
+
{renderEditableField({
|
|
617
|
+
label: 'OPENAI_API_ENDPOINT',
|
|
618
|
+
field: 'openAIApiEndpoint',
|
|
619
|
+
placeholder: 'Enter a new OpenAI API endpoint',
|
|
620
|
+
})}
|
|
621
|
+
{renderEditableField({
|
|
622
|
+
label: 'OPENAI_API_KEY',
|
|
623
|
+
field: 'openAIApiKey',
|
|
624
|
+
placeholder: 'Enter a new OpenAI API key',
|
|
625
|
+
})}
|
|
626
|
+
<Flex justify="flex-end">
|
|
627
|
+
<Button
|
|
628
|
+
colorPalette="teal"
|
|
629
|
+
loading={savingSection === 'openai'}
|
|
630
|
+
disabled={!isSectionDirty('openai')}
|
|
631
|
+
onClick={() => saveSection('openai')}
|
|
632
|
+
>
|
|
633
|
+
Save
|
|
634
|
+
</Button>
|
|
635
|
+
</Flex>
|
|
636
|
+
</Stack>
|
|
637
|
+
</Box>
|
|
638
|
+
|
|
639
|
+
<Box
|
|
640
|
+
borderWidth="1px"
|
|
641
|
+
borderColor="gray.700"
|
|
642
|
+
borderRadius="lg"
|
|
643
|
+
p={4}
|
|
644
|
+
bg="gray.900"
|
|
645
|
+
>
|
|
646
|
+
<Stack gap={4}>
|
|
647
|
+
<Box>
|
|
648
|
+
<Text fontWeight="600">Coinalyze</Text>
|
|
649
|
+
<Text fontSize="sm" color="gray.400">
|
|
650
|
+
API key stored in the user profile for derivatives
|
|
651
|
+
data ingestion.
|
|
652
|
+
</Text>
|
|
653
|
+
</Box>
|
|
654
|
+
{renderEditableField({
|
|
655
|
+
label: 'COINALYZE_API_KEY',
|
|
656
|
+
field: 'coinalyzeApiKey',
|
|
657
|
+
placeholder: 'Enter a new Coinalyze API key',
|
|
658
|
+
})}
|
|
659
|
+
<Flex justify="flex-end">
|
|
660
|
+
<Button
|
|
661
|
+
colorPalette="teal"
|
|
662
|
+
loading={savingSection === 'coinalyze'}
|
|
663
|
+
disabled={!isSectionDirty('coinalyze')}
|
|
664
|
+
onClick={() => saveSection('coinalyze')}
|
|
665
|
+
>
|
|
666
|
+
Save
|
|
667
|
+
</Button>
|
|
668
|
+
</Flex>
|
|
669
|
+
</Stack>
|
|
670
|
+
</Box>
|
|
671
|
+
|
|
672
|
+
<Box
|
|
673
|
+
borderWidth="1px"
|
|
674
|
+
borderColor="gray.700"
|
|
675
|
+
borderRadius="lg"
|
|
676
|
+
p={4}
|
|
677
|
+
bg="gray.900"
|
|
678
|
+
>
|
|
679
|
+
<Stack gap={4}>
|
|
680
|
+
<Box>
|
|
681
|
+
<Text fontWeight="600">Telegram</Text>
|
|
682
|
+
<Text fontSize="sm" color="gray.400">
|
|
683
|
+
Bot credentials used for signal delivery.
|
|
684
|
+
</Text>
|
|
685
|
+
</Box>
|
|
686
|
+
{renderEditableField({
|
|
687
|
+
label: 'TG_BOT_TOKEN',
|
|
688
|
+
field: 'tgBotToken',
|
|
689
|
+
placeholder: 'Enter a new Telegram bot token',
|
|
690
|
+
})}
|
|
691
|
+
{renderEditableField({
|
|
692
|
+
label: 'TG_CHAT_ID',
|
|
693
|
+
field: 'tgChatId',
|
|
694
|
+
placeholder: 'Enter Telegram chat ID',
|
|
695
|
+
})}
|
|
696
|
+
<Flex justify="flex-end">
|
|
697
|
+
<Button
|
|
698
|
+
colorPalette="teal"
|
|
699
|
+
loading={savingSection === 'telegram'}
|
|
700
|
+
disabled={!isSectionDirty('telegram')}
|
|
701
|
+
onClick={() => saveSection('telegram')}
|
|
702
|
+
>
|
|
703
|
+
Save
|
|
704
|
+
</Button>
|
|
705
|
+
</Flex>
|
|
706
|
+
</Stack>
|
|
707
|
+
</Box>
|
|
708
|
+
|
|
709
|
+
<Box
|
|
710
|
+
borderWidth="1px"
|
|
711
|
+
borderColor="gray.700"
|
|
712
|
+
borderRadius="lg"
|
|
713
|
+
p={4}
|
|
714
|
+
bg="gray.900"
|
|
715
|
+
>
|
|
716
|
+
<Stack gap={4}>
|
|
717
|
+
<Box>
|
|
718
|
+
<Text fontWeight="600">Password</Text>
|
|
719
|
+
<Text fontSize="sm" color="gray.400">
|
|
720
|
+
Update the password used for sign in.
|
|
721
|
+
</Text>
|
|
722
|
+
</Box>
|
|
723
|
+
<Field.Root>
|
|
724
|
+
<Field.Label>Password</Field.Label>
|
|
725
|
+
<Input
|
|
726
|
+
type="password"
|
|
727
|
+
value={passwords.password}
|
|
728
|
+
onChange={(event) =>
|
|
729
|
+
setPasswords((current) => ({
|
|
730
|
+
...current,
|
|
731
|
+
password: event.target.value,
|
|
732
|
+
}))
|
|
733
|
+
}
|
|
734
|
+
/>
|
|
735
|
+
</Field.Root>
|
|
736
|
+
<Field.Root>
|
|
737
|
+
<Field.Label>Confirm password</Field.Label>
|
|
738
|
+
<Input
|
|
739
|
+
type="password"
|
|
740
|
+
value={passwords.confirmPassword}
|
|
741
|
+
onChange={(event) =>
|
|
742
|
+
setPasswords((current) => ({
|
|
743
|
+
...current,
|
|
744
|
+
confirmPassword: event.target.value,
|
|
745
|
+
}))
|
|
746
|
+
}
|
|
747
|
+
/>
|
|
748
|
+
</Field.Root>
|
|
749
|
+
{passwordError ? (
|
|
750
|
+
<Text fontSize="sm" color="red.300">
|
|
751
|
+
{passwordError}
|
|
752
|
+
</Text>
|
|
753
|
+
) : null}
|
|
754
|
+
<Flex justify="flex-end">
|
|
755
|
+
<Button
|
|
756
|
+
colorPalette="teal"
|
|
757
|
+
loading={savingSection === 'password'}
|
|
758
|
+
disabled={
|
|
759
|
+
!isSectionDirty('password') ||
|
|
760
|
+
Boolean(passwordError)
|
|
761
|
+
}
|
|
762
|
+
onClick={() => saveSection('password')}
|
|
763
|
+
>
|
|
764
|
+
Save
|
|
765
|
+
</Button>
|
|
766
|
+
</Flex>
|
|
767
|
+
</Stack>
|
|
768
|
+
</Box>
|
|
769
|
+
|
|
770
|
+
<Box
|
|
771
|
+
borderWidth="1px"
|
|
772
|
+
borderColor="gray.700"
|
|
773
|
+
borderRadius="lg"
|
|
774
|
+
p={4}
|
|
775
|
+
bg="gray.900"
|
|
776
|
+
>
|
|
777
|
+
<Stack gap={4}>
|
|
778
|
+
<Box>
|
|
779
|
+
<Text fontWeight="600">Passwordless auth token</Text>
|
|
780
|
+
<Text fontSize="sm" color="gray.400">
|
|
781
|
+
Token accepted in dashboard links for instant auth.
|
|
782
|
+
</Text>
|
|
783
|
+
</Box>
|
|
784
|
+
{renderEditableField({
|
|
785
|
+
label: 'TOKEN',
|
|
786
|
+
field: 'token',
|
|
787
|
+
placeholder: 'Enter a new passwordless token',
|
|
788
|
+
})}
|
|
789
|
+
<Flex justify="flex-end">
|
|
790
|
+
<Button
|
|
791
|
+
colorPalette="teal"
|
|
792
|
+
loading={savingSection === 'token'}
|
|
793
|
+
disabled={!isSectionDirty('token')}
|
|
794
|
+
onClick={() => saveSection('token')}
|
|
795
|
+
>
|
|
796
|
+
Save
|
|
797
|
+
</Button>
|
|
798
|
+
</Flex>
|
|
799
|
+
</Stack>
|
|
800
|
+
</Box>
|
|
801
|
+
</Stack>
|
|
802
|
+
)}
|
|
803
|
+
</Drawer.Body>
|
|
804
|
+
</Drawer.Content>
|
|
805
|
+
</Drawer.Positioner>
|
|
806
|
+
</Portal>
|
|
807
|
+
</Drawer.Root>
|
|
808
|
+
);
|
|
809
|
+
};
|