@peers-app/peers-ui 0.15.5 → 0.16.1
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/dist/components/markdown-editor/editor.js +2 -1
- package/dist/components/markdown-editor/move-line-plugin.d.ts +11 -1
- package/dist/components/markdown-editor/move-line-plugin.js +83 -18
- package/dist/components/markdown-editor/select-line-boundary-plugin.d.ts +8 -0
- package/dist/components/markdown-editor/select-line-boundary-plugin.js +71 -0
- package/dist/components/router.js +4 -0
- package/dist/screens/account/account-screen.d.ts +1 -0
- package/dist/screens/account/account-screen.js +60 -0
- package/dist/screens/data-explorer/data-explorer.js +64 -16
- package/dist/screens/setup-user.js +4 -0
- package/dist/system-apps/account.app.d.ts +2 -0
- package/dist/system-apps/account.app.js +8 -0
- package/dist/system-apps/index.d.ts +1 -0
- package/dist/system-apps/index.js +5 -1
- package/package.json +3 -3
- package/src/components/markdown-editor/editor.tsx +2 -0
- package/src/components/markdown-editor/move-line-plugin.tsx +89 -19
- package/src/components/markdown-editor/select-line-boundary-plugin.tsx +95 -0
- package/src/components/router.tsx +4 -0
- package/src/screens/account/account-screen.tsx +141 -0
- package/src/screens/data-explorer/data-explorer.tsx +271 -76
- package/src/screens/setup-user.tsx +5 -0
- package/src/system-apps/account.app.ts +7 -0
- package/src/system-apps/index.ts +3 -0
- package/docs/conversation-tab.md +0 -201
- package/docs/getting-started.md +0 -284
- package/docs/knowledge.md +0 -187
- package/docs/tabs-ui.md +0 -681
- package/docs/user-contacts-ui.md +0 -384
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.16.1",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-env": "^7.24.5",
|
|
40
40
|
"@babel/preset-react": "^7.24.1",
|
|
41
41
|
"@babel/preset-typescript": "^7.27.1",
|
|
42
|
-
"@peers-app/peers-sdk": "0.
|
|
42
|
+
"@peers-app/peers-sdk": "0.16.1",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -22,6 +22,7 @@ import { customMarkdownTransformers, MarkdownPlugin } from "./markdown-plugin";
|
|
|
22
22
|
import { MentionNode } from "./mention-node";
|
|
23
23
|
import { MentionsPlugin } from "./mentions-plugin";
|
|
24
24
|
import { MoveLineWithAltArrowsPlugin } from "./move-line-plugin";
|
|
25
|
+
import { SelectToLineBoundaryPlugin } from "./select-line-boundary-plugin";
|
|
25
26
|
import theme from "./theme";
|
|
26
27
|
import { type IToolbarControl, ToolbarPlugin } from "./toolbar";
|
|
27
28
|
|
|
@@ -121,6 +122,7 @@ export function MarkdownEditor(props: IMarkdownEditorProps) {
|
|
|
121
122
|
<CheckListPlugin />
|
|
122
123
|
<TabIndentationPlugin />
|
|
123
124
|
<MoveLineWithAltArrowsPlugin mentionsOpen={mentionsOpen} />
|
|
125
|
+
<SelectToLineBoundaryPlugin />
|
|
124
126
|
<OnKeyDownPlugin effects={props.effects} mentionsOpen={mentionsOpen} />
|
|
125
127
|
</div>
|
|
126
128
|
</div>
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
$createRangeSelection,
|
|
6
6
|
$getNodeByKey,
|
|
7
7
|
$getSelection,
|
|
8
|
+
$isElementNode,
|
|
8
9
|
$isRangeSelection,
|
|
9
10
|
$isRootOrShadowRoot,
|
|
10
11
|
$setSelection,
|
|
@@ -15,10 +16,11 @@ import {
|
|
|
15
16
|
import { useEffect } from "react";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* Walks up from `node` to find the reorderable block for keyboard shortcuts that
|
|
20
|
+
* operate on "lines" (blocks). Returns a list item when inside a list, or the
|
|
21
|
+
* top-level block directly under the root.
|
|
20
22
|
*/
|
|
21
|
-
function $getMovableBlock(node: LexicalNode): LexicalNode | null {
|
|
23
|
+
export function $getMovableBlock(node: LexicalNode): LexicalNode | null {
|
|
22
24
|
let n: LexicalNode | null = node;
|
|
23
25
|
while (n !== null) {
|
|
24
26
|
const parent: LexicalNode | null = n.getParent();
|
|
@@ -37,7 +39,26 @@ function $getMovableBlock(node: LexicalNode): LexicalNode | null {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
42
|
+
* Returns true when `node` sits at the absolute start of `block` — i.e. it is
|
|
43
|
+
* the block itself or lies along the first-child path from block down to node.
|
|
44
|
+
* Used to detect a selection endpoint that bled into the next block at offset 0.
|
|
45
|
+
*/
|
|
46
|
+
function $isFirstPositionInBlock(node: LexicalNode, block: LexicalNode): boolean {
|
|
47
|
+
let n: LexicalNode | null = node;
|
|
48
|
+
while (n !== null && n !== block) {
|
|
49
|
+
if (n.getPreviousSibling() !== null) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
n = n.getParent();
|
|
53
|
+
}
|
|
54
|
+
return n === block;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Registers Alt/Option + ArrowUp/ArrowDown to move the selected block(s) — or the
|
|
59
|
+
* block at the caret — past an adjacent sibling. Supports both collapsed cursors and
|
|
60
|
+
* multi-block selections: the single neighbor block is relocated to the other side of
|
|
61
|
+
* the selection range, which effectively shifts the selected blocks up or down by one.
|
|
41
62
|
* @param props.mentionsOpen — when true, the plugin defers so mention typeahead keeps keyboard focus.
|
|
42
63
|
*/
|
|
43
64
|
export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<boolean> }) {
|
|
@@ -64,34 +85,83 @@ export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<bo
|
|
|
64
85
|
// built-in arrow handler + browser would move the caret on top of our swap.
|
|
65
86
|
|
|
66
87
|
const selection = $getSelection();
|
|
67
|
-
if (!$isRangeSelection(selection)
|
|
88
|
+
if (!$isRangeSelection(selection)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const anchorBlock = $getMovableBlock(selection.anchor.getNode());
|
|
93
|
+
const focusBlock = $getMovableBlock(selection.focus.getNode());
|
|
94
|
+
if (anchorBlock === null || focusBlock === null) {
|
|
68
95
|
return false;
|
|
69
96
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
97
|
+
|
|
98
|
+
// Both endpoints must share the same parent (same nesting level).
|
|
99
|
+
if (anchorBlock.getParent()?.getKey() !== focusBlock.getParent()?.getKey()) {
|
|
73
100
|
return false;
|
|
74
101
|
}
|
|
75
102
|
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
const anchorIdx = anchorBlock.getIndexWithinParent();
|
|
104
|
+
const focusIdx = focusBlock.getIndexWithinParent();
|
|
105
|
+
const firstBlock = anchorIdx <= focusIdx ? anchorBlock : focusBlock;
|
|
106
|
+
let lastBlock = anchorIdx <= focusIdx ? focusBlock : anchorBlock;
|
|
107
|
+
|
|
108
|
+
let anchorKey = selection.anchor.key;
|
|
109
|
+
let anchorOffset = selection.anchor.offset;
|
|
110
|
+
let anchorType = selection.anchor.type;
|
|
111
|
+
let focusKey = selection.focus.key;
|
|
112
|
+
let focusOffset = selection.focus.offset;
|
|
113
|
+
let focusType = selection.focus.type;
|
|
114
|
+
|
|
115
|
+
// When a selection extends to offset 0 of the next block (common when
|
|
116
|
+
// selecting to "end of line"), that trailing block has no selected
|
|
117
|
+
// content — exclude it so it isn't dragged along. Also clamp the
|
|
118
|
+
// trailing selection endpoint to the end of the new lastBlock so the
|
|
119
|
+
// restored highlight doesn't overextend into the excluded block.
|
|
120
|
+
if (lastBlock !== firstBlock) {
|
|
121
|
+
const lastPoint = anchorIdx > focusIdx ? selection.anchor : selection.focus;
|
|
122
|
+
if (lastPoint.offset === 0 && $isFirstPositionInBlock(lastPoint.getNode(), lastBlock)) {
|
|
123
|
+
const stepped = lastBlock.getPreviousSibling();
|
|
124
|
+
if (stepped === null) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
lastBlock = stepped;
|
|
128
|
+
|
|
129
|
+
const lastDesc = $isElementNode(lastBlock) ? lastBlock.getLastDescendant() : null;
|
|
130
|
+
const clampKey = lastDesc ? lastDesc.getKey() : lastBlock.getKey();
|
|
131
|
+
const clampOffset = lastDesc
|
|
132
|
+
? lastDesc.getTextContentSize()
|
|
133
|
+
: $isElementNode(lastBlock)
|
|
134
|
+
? lastBlock.getChildrenSize()
|
|
135
|
+
: lastBlock.getTextContentSize();
|
|
136
|
+
const clampType: "text" | "element" = lastDesc
|
|
137
|
+
? "text"
|
|
138
|
+
: $isElementNode(lastBlock)
|
|
139
|
+
? "element"
|
|
140
|
+
: "text";
|
|
141
|
+
if (anchorIdx > focusIdx) {
|
|
142
|
+
anchorKey = clampKey;
|
|
143
|
+
anchorOffset = clampOffset;
|
|
144
|
+
anchorType = clampType;
|
|
145
|
+
} else {
|
|
146
|
+
focusKey = clampKey;
|
|
147
|
+
focusOffset = clampOffset;
|
|
148
|
+
focusType = clampType;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
82
152
|
|
|
83
153
|
if (code === "ArrowUp") {
|
|
84
|
-
const prev =
|
|
154
|
+
const prev = firstBlock.getPreviousSibling();
|
|
85
155
|
if (prev === null) {
|
|
86
156
|
return false;
|
|
87
157
|
}
|
|
88
|
-
|
|
158
|
+
lastBlock.insertAfter(prev, false);
|
|
89
159
|
} else {
|
|
90
|
-
const next =
|
|
160
|
+
const next = lastBlock.getNextSibling();
|
|
91
161
|
if (next === null) {
|
|
92
162
|
return false;
|
|
93
163
|
}
|
|
94
|
-
|
|
164
|
+
firstBlock.insertBefore(next, false);
|
|
95
165
|
}
|
|
96
166
|
|
|
97
167
|
if ($getNodeByKey(anchorKey) !== null) {
|
|
@@ -102,7 +172,7 @@ export function MoveLineWithAltArrowsPlugin(props: { mentionsOpen: Observable<bo
|
|
|
102
172
|
restored.focus.set(focusKey, focusOffset, focusType);
|
|
103
173
|
$setSelection(restored);
|
|
104
174
|
} else {
|
|
105
|
-
|
|
175
|
+
lastBlock.selectEnd();
|
|
106
176
|
}
|
|
107
177
|
|
|
108
178
|
event.preventDefault();
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
2
|
+
import {
|
|
3
|
+
$createRangeSelection,
|
|
4
|
+
$getNearestNodeFromDOMNode,
|
|
5
|
+
$getSelection,
|
|
6
|
+
$isRangeSelection,
|
|
7
|
+
$isTextNode,
|
|
8
|
+
$setSelection,
|
|
9
|
+
COMMAND_PRIORITY_HIGH,
|
|
10
|
+
KEY_DOWN_COMMAND,
|
|
11
|
+
} from "lexical";
|
|
12
|
+
import { useEffect } from "react";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Intercepts Cmd+Shift+ArrowLeft/Right (Mac line-boundary selection) and explicitly
|
|
16
|
+
* sets anchor/focus so that Lexical's reconciliation cannot flip the selection direction.
|
|
17
|
+
* Uses the browser's native `Selection.modify` with `'lineboundary'` granularity to
|
|
18
|
+
* find the correct **visual** line boundary (respecting text wrapping), then converts
|
|
19
|
+
* the result back to Lexical coordinates.
|
|
20
|
+
*/
|
|
21
|
+
export function SelectToLineBoundaryPlugin() {
|
|
22
|
+
const [editor] = useLexicalComposerContext();
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
return editor.registerCommand(
|
|
26
|
+
KEY_DOWN_COMMAND,
|
|
27
|
+
(event: KeyboardEvent) => {
|
|
28
|
+
if (!event.shiftKey || !event.metaKey || event.altKey || event.ctrlKey) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const code = event.code;
|
|
32
|
+
if (code !== "ArrowLeft" && code !== "ArrowRight") {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const selection = $getSelection();
|
|
37
|
+
if (!$isRangeSelection(selection)) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const savedAnchorKey = selection.anchor.key;
|
|
42
|
+
const savedAnchorOffset = selection.anchor.offset;
|
|
43
|
+
const savedAnchorType = selection.anchor.type;
|
|
44
|
+
|
|
45
|
+
const focusElement = editor.getElementByKey(selection.focus.key);
|
|
46
|
+
if (!focusElement) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const focusDomNode =
|
|
50
|
+
selection.focus.type === "text" ? focusElement.firstChild : focusElement;
|
|
51
|
+
if (!focusDomNode) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const domSelection = window.getSelection();
|
|
56
|
+
if (!domSelection) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
domSelection.collapse(focusDomNode, selection.focus.offset);
|
|
61
|
+
domSelection.modify("move", code === "ArrowLeft" ? "left" : "right", "lineboundary");
|
|
62
|
+
|
|
63
|
+
const boundaryDomNode = domSelection.anchorNode;
|
|
64
|
+
const boundaryDomOffset = domSelection.anchorOffset;
|
|
65
|
+
if (!boundaryDomNode) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const boundaryLexicalNode = $getNearestNodeFromDOMNode(boundaryDomNode);
|
|
70
|
+
if (!boundaryLexicalNode) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const newFocusKey = boundaryLexicalNode.getKey();
|
|
75
|
+
const newFocusOffset = boundaryDomOffset;
|
|
76
|
+
const newFocusType: "text" | "element" = $isTextNode(boundaryLexicalNode)
|
|
77
|
+
? "text"
|
|
78
|
+
: "element";
|
|
79
|
+
|
|
80
|
+
const restored = $createRangeSelection();
|
|
81
|
+
restored.format = selection.format;
|
|
82
|
+
restored.style = selection.style;
|
|
83
|
+
restored.anchor.set(savedAnchorKey, savedAnchorOffset, savedAnchorType);
|
|
84
|
+
restored.focus.set(newFocusKey, newFocusOffset, newFocusType);
|
|
85
|
+
$setSelection(restored);
|
|
86
|
+
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
COMMAND_PRIORITY_HIGH,
|
|
91
|
+
);
|
|
92
|
+
}, [editor]);
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as globals from "../globals";
|
|
2
2
|
import { useObservable } from "../hooks";
|
|
3
|
+
import { AccountScreen } from "../screens/account/account-screen";
|
|
3
4
|
import { AssistantDetails } from "../screens/assistants/assistant-details";
|
|
4
5
|
import { AssistantList } from "../screens/assistants/assistant-list";
|
|
5
6
|
import { PackageDetails } from "../screens/packages/package-details";
|
|
@@ -54,6 +55,9 @@ export function Router({ path: providedPath }: { path?: string } = {}) {
|
|
|
54
55
|
if (path === "settings" || path === "profile") {
|
|
55
56
|
return <SettingsPage />;
|
|
56
57
|
}
|
|
58
|
+
if (path === "account") {
|
|
59
|
+
return <AccountScreen />;
|
|
60
|
+
}
|
|
57
61
|
if (path === "threads" || path === "") {
|
|
58
62
|
return <ChannelMessages />;
|
|
59
63
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { rpcServerCalls, userVar } from "@peers-app/peers-sdk";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Tooltip } from "../../components/tooltip";
|
|
4
|
+
import { useObservable } from "../../hooks";
|
|
5
|
+
|
|
6
|
+
const registrationStatusPvar = userVar<string>("PEERS_SERVICES_REGISTERED", {
|
|
7
|
+
defaultValue: "",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const servicesTokenPvar = userVar<string>("PEERS_SERVICES_TOKEN", {
|
|
11
|
+
isSecret: true,
|
|
12
|
+
defaultValue: "",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const INFO_TEXT = `### What does registering do?
|
|
16
|
+
|
|
17
|
+
Registering your account links your **public key** with the Peers cloud service. This enables cloud-powered features like:
|
|
18
|
+
|
|
19
|
+
- **AI Assistant** - use the built-in assistant powered by hosted LLM inference
|
|
20
|
+
- **Cloud relay** - improved connectivity between your devices
|
|
21
|
+
- **Future services** - new cloud features as they become available
|
|
22
|
+
|
|
23
|
+
### Is it safe?
|
|
24
|
+
|
|
25
|
+
Registration uses a **cryptographic challenge-response** to prove you own your secret key.
|
|
26
|
+
Your **secret key never leaves your device**. Only your public key is shared with the server.
|
|
27
|
+
|
|
28
|
+
### Do I have to register?
|
|
29
|
+
|
|
30
|
+
No. Peers works fully in local/peer-to-peer mode without registration. Cloud features simply won't be available until you register.`;
|
|
31
|
+
|
|
32
|
+
export function AccountScreen() {
|
|
33
|
+
const [registrationStatus] = useObservable(registrationStatusPvar);
|
|
34
|
+
const [isRegistering, setIsRegistering] = useState(false);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const isRegistered = !!registrationStatus;
|
|
39
|
+
|
|
40
|
+
const handleRegister = async () => {
|
|
41
|
+
setIsRegistering(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
setSuccess(null);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const token = await rpcServerCalls.registerWithPeersServices();
|
|
47
|
+
servicesTokenPvar(token);
|
|
48
|
+
registrationStatusPvar(new Date().toISOString());
|
|
49
|
+
setSuccess("Account registered successfully.");
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(`Registration failed: ${String(err)}`);
|
|
52
|
+
} finally {
|
|
53
|
+
setIsRegistering(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="container-fluid p-3" style={{ maxWidth: 600 }}>
|
|
59
|
+
<h4 className="mb-3 d-flex align-items-center gap-2">
|
|
60
|
+
<i className="bi bi-person-circle" />
|
|
61
|
+
Account
|
|
62
|
+
</h4>
|
|
63
|
+
|
|
64
|
+
{error && (
|
|
65
|
+
<div className="alert alert-danger alert-dismissible" role="alert">
|
|
66
|
+
<i className="bi bi-exclamation-triangle me-2" />
|
|
67
|
+
{error}
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className="btn-close"
|
|
71
|
+
onClick={() => setError(null)}
|
|
72
|
+
aria-label="Close"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{success && (
|
|
78
|
+
<div className="alert alert-success alert-dismissible" role="alert">
|
|
79
|
+
<i className="bi bi-check-circle me-2" />
|
|
80
|
+
{success}
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className="btn-close"
|
|
84
|
+
onClick={() => setSuccess(null)}
|
|
85
|
+
aria-label="Close"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
<div className="card">
|
|
91
|
+
<div className="card-body">
|
|
92
|
+
<h5 className="card-title d-flex align-items-center gap-2">
|
|
93
|
+
Peers Services Registration
|
|
94
|
+
<Tooltip markdownContent={INFO_TEXT} />
|
|
95
|
+
</h5>
|
|
96
|
+
|
|
97
|
+
{isRegistered ? (
|
|
98
|
+
<div>
|
|
99
|
+
<div className="d-flex align-items-center gap-2 mb-2">
|
|
100
|
+
<i className="bi bi-check-circle-fill text-success" style={{ fontSize: "1.2em" }} />
|
|
101
|
+
<span>Registered</span>
|
|
102
|
+
</div>
|
|
103
|
+
<small className="text-muted">
|
|
104
|
+
Registered on{" "}
|
|
105
|
+
{new Date(registrationStatus as string).toLocaleDateString(undefined, {
|
|
106
|
+
year: "numeric",
|
|
107
|
+
month: "long",
|
|
108
|
+
day: "numeric",
|
|
109
|
+
})}
|
|
110
|
+
</small>
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
<div>
|
|
114
|
+
<p className="text-muted mb-3">
|
|
115
|
+
Register your account to enable cloud-powered features like the AI assistant.
|
|
116
|
+
</p>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="btn btn-primary"
|
|
120
|
+
onClick={handleRegister}
|
|
121
|
+
disabled={isRegistering}
|
|
122
|
+
>
|
|
123
|
+
{isRegistering ? (
|
|
124
|
+
<>
|
|
125
|
+
<span className="spinner-border spinner-border-sm me-2" />
|
|
126
|
+
Registering...
|
|
127
|
+
</>
|
|
128
|
+
) : (
|
|
129
|
+
<>
|
|
130
|
+
<i className="bi bi-cloud-upload me-2" />
|
|
131
|
+
Register Account
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|