@schlessera/brain-ui-react 0.4.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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/components/chat/ask-user-card.d.ts +12 -0
- package/dist/components/chat/ask-user-card.d.ts.map +1 -0
- package/dist/components/chat/ask-user-card.js +185 -0
- package/dist/components/chat/ask-user-card.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +35 -0
- package/dist/components/chat/brain-markdown.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown.js +363 -0
- package/dist/components/chat/brain-markdown.js.map +1 -0
- package/dist/components/chat/chat-page.d.ts +2 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -0
- package/dist/components/chat/chat-page.js +442 -0
- package/dist/components/chat/chat-page.js.map +1 -0
- package/dist/components/chat/command-palette.d.ts +5 -0
- package/dist/components/chat/command-palette.d.ts.map +1 -0
- package/dist/components/chat/command-palette.js +18 -0
- package/dist/components/chat/command-palette.js.map +1 -0
- package/dist/components/chat/markdown-content.d.ts +4 -0
- package/dist/components/chat/markdown-content.d.ts.map +1 -0
- package/dist/components/chat/markdown-content.js +6 -0
- package/dist/components/chat/markdown-content.js.map +1 -0
- package/dist/components/chat/message-bubble.d.ts +9 -0
- package/dist/components/chat/message-bubble.d.ts.map +1 -0
- package/dist/components/chat/message-bubble.js +129 -0
- package/dist/components/chat/message-bubble.js.map +1 -0
- package/dist/components/chat/message-share.d.ts +10 -0
- package/dist/components/chat/message-share.d.ts.map +1 -0
- package/dist/components/chat/message-share.js +65 -0
- package/dist/components/chat/message-share.js.map +1 -0
- package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
- package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/claude-tools.js +38 -0
- package/dist/components/chat/renderers/claude-tools.js.map +1 -0
- package/dist/components/chat/renderers/generic.d.ts +9 -0
- package/dist/components/chat/renderers/generic.d.ts.map +1 -0
- package/dist/components/chat/renderers/generic.js +68 -0
- package/dist/components/chat/renderers/generic.js.map +1 -0
- package/dist/components/chat/renderers/index.d.ts +5 -0
- package/dist/components/chat/renderers/index.d.ts.map +1 -0
- package/dist/components/chat/renderers/index.js +17 -0
- package/dist/components/chat/renderers/index.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +20 -0
- package/dist/components/chat/risk-hints.d.ts.map +1 -0
- package/dist/components/chat/risk-hints.js +81 -0
- package/dist/components/chat/risk-hints.js.map +1 -0
- package/dist/components/chat/session-drawer.d.ts +6 -0
- package/dist/components/chat/session-drawer.d.ts.map +1 -0
- package/dist/components/chat/session-drawer.js +96 -0
- package/dist/components/chat/session-drawer.js.map +1 -0
- package/dist/components/chat/share-block.d.ts +9 -0
- package/dist/components/chat/share-block.d.ts.map +1 -0
- package/dist/components/chat/share-block.js +70 -0
- package/dist/components/chat/share-block.js.map +1 -0
- package/dist/components/chat/tool-call-timeline.d.ts +8 -0
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
- package/dist/components/chat/tool-call-timeline.js +100 -0
- package/dist/components/chat/tool-call-timeline.js.map +1 -0
- package/dist/components/chat/tool-views.d.ts +116 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -0
- package/dist/components/chat/tool-views.js +628 -0
- package/dist/components/chat/tool-views.js.map +1 -0
- package/dist/components/chat/welcome-state.d.ts +4 -0
- package/dist/components/chat/welcome-state.d.ts.map +1 -0
- package/dist/components/chat/welcome-state.js +10 -0
- package/dist/components/chat/welcome-state.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts +5 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
- package/dist/components/connectivity/connection-gate.js +42 -0
- package/dist/components/connectivity/connection-gate.js.map +1 -0
- package/dist/components/connectivity/login-screen.d.ts +9 -0
- package/dist/components/connectivity/login-screen.d.ts.map +1 -0
- package/dist/components/connectivity/login-screen.js +99 -0
- package/dist/components/connectivity/login-screen.js.map +1 -0
- package/dist/components/files/file-panel.d.ts +5 -0
- package/dist/components/files/file-panel.d.ts.map +1 -0
- package/dist/components/files/file-panel.js +46 -0
- package/dist/components/files/file-panel.js.map +1 -0
- package/dist/components/files/file-tree.d.ts +2 -0
- package/dist/components/files/file-tree.d.ts.map +1 -0
- package/dist/components/files/file-tree.js +63 -0
- package/dist/components/files/file-tree.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts +5 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
- package/dist/components/files/file-viewer-binary.js +20 -0
- package/dist/components/files/file-viewer-binary.js.map +1 -0
- package/dist/components/files/file-viewer-html.d.ts +4 -0
- package/dist/components/files/file-viewer-html.d.ts.map +1 -0
- package/dist/components/files/file-viewer-html.js +5 -0
- package/dist/components/files/file-viewer-html.js.map +1 -0
- package/dist/components/files/file-viewer-markdown.d.ts +4 -0
- package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
- package/dist/components/files/file-viewer-markdown.js +10 -0
- package/dist/components/files/file-viewer-markdown.js.map +1 -0
- package/dist/components/files/file-viewer-raw.d.ts +5 -0
- package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
- package/dist/components/files/file-viewer-raw.js +15 -0
- package/dist/components/files/file-viewer-raw.js.map +1 -0
- package/dist/components/files/file-viewer.d.ts +2 -0
- package/dist/components/files/file-viewer.d.ts.map +1 -0
- package/dist/components/files/file-viewer.js +175 -0
- package/dist/components/files/file-viewer.js.map +1 -0
- package/dist/components/files/frontmatter-panel.d.ts +5 -0
- package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
- package/dist/components/files/frontmatter-panel.js +12 -0
- package/dist/components/files/frontmatter-panel.js.map +1 -0
- package/dist/components/layout/app-shell.d.ts +5 -0
- package/dist/components/layout/app-shell.d.ts.map +1 -0
- package/dist/components/layout/app-shell.js +10 -0
- package/dist/components/layout/app-shell.js.map +1 -0
- package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
- package/dist/components/layout/mobile-tab-bar.js +16 -0
- package/dist/components/layout/mobile-tab-bar.js.map +1 -0
- package/dist/components/layout/side-rail.d.ts +2 -0
- package/dist/components/layout/side-rail.d.ts.map +1 -0
- package/dist/components/layout/side-rail.js +26 -0
- package/dist/components/layout/side-rail.js.map +1 -0
- package/dist/components/layout/slide-panel.d.ts +9 -0
- package/dist/components/layout/slide-panel.d.ts.map +1 -0
- package/dist/components/layout/slide-panel.js +18 -0
- package/dist/components/layout/slide-panel.js.map +1 -0
- package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/streaming-modal.js +92 -0
- package/dist/components/quick-actions/streaming-modal.js.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.js +82 -0
- package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
- package/dist/components/settings/passkey-panel.d.ts +10 -0
- package/dist/components/settings/passkey-panel.d.ts.map +1 -0
- package/dist/components/settings/passkey-panel.js +118 -0
- package/dist/components/settings/passkey-panel.js.map +1 -0
- package/dist/components/share/share-menu.d.ts +23 -0
- package/dist/components/share/share-menu.d.ts.map +1 -0
- package/dist/components/share/share-menu.js +58 -0
- package/dist/components/share/share-menu.js.map +1 -0
- package/dist/components/voice/dictation-sheet.d.ts +6 -0
- package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
- package/dist/components/voice/dictation-sheet.js +91 -0
- package/dist/components/voice/dictation-sheet.js.map +1 -0
- package/dist/components/voice/mic-button.d.ts +6 -0
- package/dist/components/voice/mic-button.d.ts.map +1 -0
- package/dist/components/voice/mic-button.js +9 -0
- package/dist/components/voice/mic-button.js.map +1 -0
- package/dist/components/voice/review-card.d.ts +8 -0
- package/dist/components/voice/review-card.d.ts.map +1 -0
- package/dist/components/voice/review-card.js +8 -0
- package/dist/components/voice/review-card.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +10 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +2 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -0
- package/dist/hooks/use-vpn-status.js +71 -0
- package/dist/hooks/use-vpn-status.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +8 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -0
- package/dist/hooks/use-websocket.js +366 -0
- package/dist/hooks/use-websocket.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-client.d.ts +115 -0
- package/dist/lib/api-client.d.ts.map +1 -0
- package/dist/lib/api-client.js +70 -0
- package/dist/lib/api-client.js.map +1 -0
- package/dist/lib/backend.d.ts +7 -0
- package/dist/lib/backend.d.ts.map +1 -0
- package/dist/lib/backend.js +33 -0
- package/dist/lib/backend.js.map +1 -0
- package/dist/lib/frontmatter.d.ts +21 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +88 -0
- package/dist/lib/frontmatter.js.map +1 -0
- package/dist/lib/image-attachments.d.ts +36 -0
- package/dist/lib/image-attachments.d.ts.map +1 -0
- package/dist/lib/image-attachments.js +175 -0
- package/dist/lib/image-attachments.js.map +1 -0
- package/dist/lib/passkeys.d.ts +18 -0
- package/dist/lib/passkeys.d.ts.map +1 -0
- package/dist/lib/passkeys.js +36 -0
- package/dist/lib/passkeys.js.map +1 -0
- package/dist/lib/share.d.ts +53 -0
- package/dist/lib/share.d.ts.map +1 -0
- package/dist/lib/share.js +144 -0
- package/dist/lib/share.js.map +1 -0
- package/dist/lib/strip-markdown.d.ts +10 -0
- package/dist/lib/strip-markdown.d.ts.map +1 -0
- package/dist/lib/strip-markdown.js +37 -0
- package/dist/lib/strip-markdown.js.map +1 -0
- package/dist/lib/tool-names.d.ts +17 -0
- package/dist/lib/tool-names.d.ts.map +1 -0
- package/dist/lib/tool-names.js +23 -0
- package/dist/lib/tool-names.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/lib/ws-client.d.ts +26 -0
- package/dist/lib/ws-client.d.ts.map +1 -0
- package/dist/lib/ws-client.js +93 -0
- package/dist/lib/ws-client.js.map +1 -0
- package/dist/stores/chat-store.d.ts +131 -0
- package/dist/stores/chat-store.d.ts.map +1 -0
- package/dist/stores/chat-store.js +376 -0
- package/dist/stores/chat-store.js.map +1 -0
- package/dist/stores/connection-store.d.ts +11 -0
- package/dist/stores/connection-store.d.ts.map +1 -0
- package/dist/stores/connection-store.js +8 -0
- package/dist/stores/connection-store.js.map +1 -0
- package/dist/stores/file-store.d.ts +53 -0
- package/dist/stores/file-store.d.ts.map +1 -0
- package/dist/stores/file-store.js +284 -0
- package/dist/stores/file-store.js.map +1 -0
- package/dist/stores/provider-store.d.ts +24 -0
- package/dist/stores/provider-store.d.ts.map +1 -0
- package/dist/stores/provider-store.js +56 -0
- package/dist/stores/provider-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +21 -0
- package/dist/stores/ui-store.d.ts.map +1 -0
- package/dist/stores/ui-store.js +23 -0
- package/dist/stores/ui-store.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/voice/asr-clients.d.ts +2 -0
- package/dist/voice/asr-clients.d.ts.map +1 -0
- package/dist/voice/asr-clients.js +33 -0
- package/dist/voice/asr-clients.js.map +1 -0
- package/dist/voice/asr-deepgram.d.ts +38 -0
- package/dist/voice/asr-deepgram.d.ts.map +1 -0
- package/dist/voice/asr-deepgram.js +307 -0
- package/dist/voice/asr-deepgram.js.map +1 -0
- package/dist/voice/asr-webspeech.d.ts +23 -0
- package/dist/voice/asr-webspeech.d.ts.map +1 -0
- package/dist/voice/asr-webspeech.js +93 -0
- package/dist/voice/asr-webspeech.js.map +1 -0
- package/dist/voice/use-dictation.d.ts +6 -0
- package/dist/voice/use-dictation.d.ts.map +1 -0
- package/dist/voice/use-dictation.js +174 -0
- package/dist/voice/use-dictation.js.map +1 -0
- package/dist/voice/voice-store.d.ts +26 -0
- package/dist/voice/voice-store.d.ts.map +1 -0
- package/dist/voice/voice-store.js +27 -0
- package/dist/voice/voice-store.js.map +1 -0
- package/package.json +71 -0
- package/src/components/chat/ask-user-card.tsx +451 -0
- package/src/components/chat/brain-markdown.tsx +547 -0
- package/src/components/chat/chat-page.tsx +820 -0
- package/src/components/chat/command-palette.tsx +68 -0
- package/src/components/chat/markdown-content.tsx +5 -0
- package/src/components/chat/message-bubble.tsx +362 -0
- package/src/components/chat/message-share.ts +78 -0
- package/src/components/chat/renderers/claude-tools.tsx +53 -0
- package/src/components/chat/renderers/generic.tsx +85 -0
- package/src/components/chat/renderers/index.ts +19 -0
- package/src/components/chat/risk-hints.ts +95 -0
- package/src/components/chat/session-drawer.tsx +213 -0
- package/src/components/chat/share-block.tsx +128 -0
- package/src/components/chat/tool-call-timeline.tsx +322 -0
- package/src/components/chat/tool-views.tsx +898 -0
- package/src/components/chat/welcome-state.tsx +105 -0
- package/src/components/connectivity/connection-gate.tsx +137 -0
- package/src/components/connectivity/login-screen.tsx +169 -0
- package/src/components/files/file-panel.tsx +116 -0
- package/src/components/files/file-tree.tsx +143 -0
- package/src/components/files/file-viewer-binary.tsx +63 -0
- package/src/components/files/file-viewer-html.tsx +10 -0
- package/src/components/files/file-viewer-markdown.tsx +14 -0
- package/src/components/files/file-viewer-raw.tsx +44 -0
- package/src/components/files/file-viewer.tsx +290 -0
- package/src/components/files/frontmatter-panel.tsx +78 -0
- package/src/components/layout/app-shell.tsx +25 -0
- package/src/components/layout/mobile-tab-bar.tsx +54 -0
- package/src/components/layout/side-rail.tsx +128 -0
- package/src/components/layout/slide-panel.tsx +64 -0
- package/src/components/quick-actions/streaming-modal.tsx +168 -0
- package/src/components/quick-actions/whatsup-modal.tsx +149 -0
- package/src/components/settings/passkey-panel.tsx +281 -0
- package/src/components/share/share-menu.tsx +121 -0
- package/src/components/voice/dictation-sheet.tsx +217 -0
- package/src/components/voice/mic-button.tsx +31 -0
- package/src/components/voice/review-card.tsx +66 -0
- package/src/config.ts +27 -0
- package/src/hooks/use-vpn-status.ts +75 -0
- package/src/hooks/use-websocket.ts +403 -0
- package/src/index.ts +46 -0
- package/src/lib/api-client.ts +173 -0
- package/src/lib/backend.ts +35 -0
- package/src/lib/frontmatter.ts +99 -0
- package/src/lib/image-attachments.ts +221 -0
- package/src/lib/passkeys.ts +50 -0
- package/src/lib/share.ts +167 -0
- package/src/lib/strip-markdown.ts +38 -0
- package/src/lib/tool-names.ts +27 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/ws-client.ts +109 -0
- package/src/stores/chat-store.ts +619 -0
- package/src/stores/connection-store.ts +23 -0
- package/src/stores/file-store.ts +344 -0
- package/src/stores/provider-store.ts +85 -0
- package/src/stores/ui-store.ts +48 -0
- package/src/styles.css +4 -0
- package/src/theme.css +425 -0
- package/src/voice/asr-clients.ts +45 -0
- package/src/voice/asr-deepgram.ts +325 -0
- package/src/voice/asr-webspeech.ts +135 -0
- package/src/voice/use-dictation.ts +187 -0
- package/src/voice/voice-store.ts +55 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Check,
|
|
4
|
+
Cloud,
|
|
5
|
+
Fingerprint,
|
|
6
|
+
Loader2,
|
|
7
|
+
LogOut,
|
|
8
|
+
Pencil,
|
|
9
|
+
Plus,
|
|
10
|
+
Trash2,
|
|
11
|
+
X,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import type { PasskeySummary } from "@schlessera/brain-ui-sdk/protocol";
|
|
14
|
+
import { api } from "../../lib/api-client.js";
|
|
15
|
+
import { isUserCancel, registerPasskey, supportsPasskeys } from "../../lib/passkeys.js";
|
|
16
|
+
import { SlidePanel } from "../layout/slide-panel.js";
|
|
17
|
+
import { cn } from "../../lib/utils.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Passkey management + sign out. Registration requires the current session
|
|
21
|
+
* (routes are behind the auth guard); credentials are RP-scoped, so entries
|
|
22
|
+
* registered on another hostname are badged and only useful there.
|
|
23
|
+
*/
|
|
24
|
+
export function PasskeyPanel({
|
|
25
|
+
open,
|
|
26
|
+
onClose,
|
|
27
|
+
}: {
|
|
28
|
+
open: boolean;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
}) {
|
|
31
|
+
const [credentials, setCredentials] = useState<PasskeySummary[]>([]);
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
const [busy, setBusy] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [passkeyMode, setPasskeyMode] = useState(true);
|
|
36
|
+
|
|
37
|
+
const webAuthnSupported = supportsPasskeys();
|
|
38
|
+
|
|
39
|
+
async function refresh() {
|
|
40
|
+
setLoading(true);
|
|
41
|
+
try {
|
|
42
|
+
const data = await api.passkeyList();
|
|
43
|
+
setCredentials(data.credentials);
|
|
44
|
+
setPasskeyMode(true);
|
|
45
|
+
} catch {
|
|
46
|
+
// 400 = server not in password mode; anything else degrades the same way.
|
|
47
|
+
setPasskeyMode(false);
|
|
48
|
+
setCredentials([]);
|
|
49
|
+
} finally {
|
|
50
|
+
setLoading(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (open) {
|
|
56
|
+
setError(null);
|
|
57
|
+
void refresh();
|
|
58
|
+
}
|
|
59
|
+
}, [open]);
|
|
60
|
+
|
|
61
|
+
async function onAdd() {
|
|
62
|
+
if (busy) return;
|
|
63
|
+
setBusy(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
await registerPasskey();
|
|
67
|
+
await refresh();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (!isUserCancel(err)) {
|
|
70
|
+
setError(err instanceof Error ? err.message : "Registration failed");
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
setBusy(false);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function onDelete(id: string) {
|
|
78
|
+
if (!window.confirm("Remove this passkey? You can't undo this.")) return;
|
|
79
|
+
setError(null);
|
|
80
|
+
try {
|
|
81
|
+
await api.passkeyDelete(id);
|
|
82
|
+
setCredentials((rows) => rows.filter((row) => row.id !== id));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
setError(err instanceof Error ? err.message : "Delete failed");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function onRename(id: string, label: string) {
|
|
89
|
+
setError(null);
|
|
90
|
+
try {
|
|
91
|
+
await api.passkeyRename(id, label);
|
|
92
|
+
setCredentials((rows) =>
|
|
93
|
+
rows.map((row) => (row.id === id ? { ...row, label } : row))
|
|
94
|
+
);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
setError(err instanceof Error ? err.message : "Rename failed");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function onSignOut() {
|
|
101
|
+
try {
|
|
102
|
+
await api.logout();
|
|
103
|
+
} finally {
|
|
104
|
+
window.location.reload();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<SlidePanel open={open} onClose={onClose} title="Security">
|
|
110
|
+
<div className="flex h-full flex-col">
|
|
111
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
112
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
113
|
+
Passkeys
|
|
114
|
+
</h3>
|
|
115
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
116
|
+
Sign in with Face ID, fingerprint, or a security key instead of the
|
|
117
|
+
password. Passkeys only work on the site they were added on.
|
|
118
|
+
</p>
|
|
119
|
+
|
|
120
|
+
{loading ? (
|
|
121
|
+
<div className="mt-6 flex justify-center">
|
|
122
|
+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
123
|
+
</div>
|
|
124
|
+
) : (
|
|
125
|
+
<ul className="mt-4 flex flex-col gap-2">
|
|
126
|
+
{credentials.map((credential) => (
|
|
127
|
+
<PasskeyRow
|
|
128
|
+
key={credential.id}
|
|
129
|
+
credential={credential}
|
|
130
|
+
onDelete={() => onDelete(credential.id)}
|
|
131
|
+
onRename={(label) => onRename(credential.id, label)}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
{credentials.length === 0 && passkeyMode && (
|
|
135
|
+
<li className="rounded-lg border border-dashed border-border-subtle p-4 text-center text-xs text-muted-foreground">
|
|
136
|
+
No passkeys yet.
|
|
137
|
+
</li>
|
|
138
|
+
)}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{error && (
|
|
143
|
+
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
144
|
+
{error}
|
|
145
|
+
</p>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{passkeyMode && webAuthnSupported ? (
|
|
149
|
+
<button
|
|
150
|
+
onClick={onAdd}
|
|
151
|
+
disabled={busy}
|
|
152
|
+
className="mt-4 flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
|
|
153
|
+
>
|
|
154
|
+
{busy ? (
|
|
155
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
156
|
+
) : (
|
|
157
|
+
<Plus className="h-4 w-4" />
|
|
158
|
+
)}
|
|
159
|
+
Add a passkey
|
|
160
|
+
</button>
|
|
161
|
+
) : (
|
|
162
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
163
|
+
{passkeyMode
|
|
164
|
+
? "This browser does not support passkeys."
|
|
165
|
+
: "Passkeys need password auth mode on the server."}
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Sign out */}
|
|
171
|
+
<div className="border-t border-border p-4">
|
|
172
|
+
<button
|
|
173
|
+
onClick={onSignOut}
|
|
174
|
+
className="flex w-full items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-destructive hover:text-destructive"
|
|
175
|
+
>
|
|
176
|
+
<LogOut className="h-4 w-4" />
|
|
177
|
+
Sign out
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</SlidePanel>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function PasskeyRow({
|
|
186
|
+
credential,
|
|
187
|
+
onDelete,
|
|
188
|
+
onRename,
|
|
189
|
+
}: {
|
|
190
|
+
credential: PasskeySummary;
|
|
191
|
+
onDelete: () => void;
|
|
192
|
+
onRename: (label: string) => void;
|
|
193
|
+
}) {
|
|
194
|
+
const [editing, setEditing] = useState(false);
|
|
195
|
+
const [draft, setDraft] = useState(credential.label);
|
|
196
|
+
const foreignRp =
|
|
197
|
+
typeof window !== "undefined" && credential.rpId !== window.location.hostname;
|
|
198
|
+
|
|
199
|
+
const displayName =
|
|
200
|
+
credential.label ||
|
|
201
|
+
`Passkey · ${new Date(credential.createdAt).toLocaleDateString()}`;
|
|
202
|
+
|
|
203
|
+
function commit() {
|
|
204
|
+
setEditing(false);
|
|
205
|
+
const label = draft.trim();
|
|
206
|
+
if (label !== credential.label) onRename(label);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<li className="rounded-lg border border-border-subtle bg-surface p-3">
|
|
211
|
+
<div className="flex items-center gap-2">
|
|
212
|
+
<Fingerprint className="h-4 w-4 shrink-0 text-primary" />
|
|
213
|
+
{editing ? (
|
|
214
|
+
<span className="flex flex-1 items-center gap-1">
|
|
215
|
+
<input
|
|
216
|
+
autoFocus
|
|
217
|
+
value={draft}
|
|
218
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
219
|
+
onKeyDown={(e) => {
|
|
220
|
+
if (e.key === "Enter") commit();
|
|
221
|
+
if (e.key === "Escape") setEditing(false);
|
|
222
|
+
}}
|
|
223
|
+
className="w-full rounded border border-border-subtle bg-background px-2 py-0.5 text-sm text-foreground outline-none focus:border-primary"
|
|
224
|
+
/>
|
|
225
|
+
<button onClick={commit} title="Save" className="p-1 text-muted-foreground hover:text-foreground">
|
|
226
|
+
<Check className="h-3.5 w-3.5" />
|
|
227
|
+
</button>
|
|
228
|
+
<button onClick={() => setEditing(false)} title="Cancel" className="p-1 text-muted-foreground hover:text-foreground">
|
|
229
|
+
<X className="h-3.5 w-3.5" />
|
|
230
|
+
</button>
|
|
231
|
+
</span>
|
|
232
|
+
) : (
|
|
233
|
+
<span className="flex-1 truncate text-sm text-foreground">{displayName}</span>
|
|
234
|
+
)}
|
|
235
|
+
{!editing && (
|
|
236
|
+
<>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => {
|
|
239
|
+
setDraft(credential.label);
|
|
240
|
+
setEditing(true);
|
|
241
|
+
}}
|
|
242
|
+
title="Rename"
|
|
243
|
+
className="p-1 text-muted-foreground transition-colors hover:text-foreground"
|
|
244
|
+
>
|
|
245
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
246
|
+
</button>
|
|
247
|
+
<button
|
|
248
|
+
onClick={onDelete}
|
|
249
|
+
title="Remove"
|
|
250
|
+
className="p-1 text-muted-foreground transition-colors hover:text-destructive"
|
|
251
|
+
>
|
|
252
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
253
|
+
</button>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-[11px] text-muted-foreground">
|
|
258
|
+
{credential.backedUp && (
|
|
259
|
+
<span className="flex items-center gap-1">
|
|
260
|
+
<Cloud className="h-3 w-3" /> Synced
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
263
|
+
{foreignRp && (
|
|
264
|
+
<span
|
|
265
|
+
className={cn(
|
|
266
|
+
"rounded bg-surface-raised px-1.5 py-0.5 font-medium text-foreground/70"
|
|
267
|
+
)}
|
|
268
|
+
title={`Registered on ${credential.rpId}; only usable there.`}
|
|
269
|
+
>
|
|
270
|
+
{credential.rpId}
|
|
271
|
+
</span>
|
|
272
|
+
)}
|
|
273
|
+
<span>
|
|
274
|
+
{credential.lastUsedAt
|
|
275
|
+
? `Last used ${new Date(credential.lastUsedAt).toLocaleDateString()}`
|
|
276
|
+
: `Added ${new Date(credential.createdAt).toLocaleDateString()}`}
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
</li>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Share2, Loader2, Check, AlertCircle } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/utils.js";
|
|
4
|
+
|
|
5
|
+
export interface ShareOption {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Optional short hint shown below the label. */
|
|
9
|
+
hint?: string;
|
|
10
|
+
/** Async action: returns true on success, false on user-cancel. */
|
|
11
|
+
run: () => Promise<boolean>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ShareMenuProps {
|
|
15
|
+
options: ShareOption[];
|
|
16
|
+
/** Aria label / tooltip for the trigger button. */
|
|
17
|
+
title?: string;
|
|
18
|
+
/** When provided, override the default icon button with a custom trigger. */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Optional render-prop for a custom trigger; receives the click handler. */
|
|
21
|
+
renderTrigger?: (props: { onClick: () => void; busy: boolean }) => React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Status = "idle" | "busy" | "done" | "error";
|
|
25
|
+
|
|
26
|
+
export function ShareMenu({ options, title = "Share", className, renderTrigger }: ShareMenuProps) {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const [status, setStatus] = useState<Status>("idle");
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!open) return;
|
|
34
|
+
const onDocClick = (e: MouseEvent) => {
|
|
35
|
+
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
|
|
36
|
+
};
|
|
37
|
+
const onKey = (e: KeyboardEvent) => {
|
|
38
|
+
if (e.key === "Escape") setOpen(false);
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener("mousedown", onDocClick);
|
|
41
|
+
document.addEventListener("keydown", onKey);
|
|
42
|
+
return () => {
|
|
43
|
+
document.removeEventListener("mousedown", onDocClick);
|
|
44
|
+
document.removeEventListener("keydown", onKey);
|
|
45
|
+
};
|
|
46
|
+
}, [open]);
|
|
47
|
+
|
|
48
|
+
// If there's only one option, the trigger should fire it directly.
|
|
49
|
+
const single = options.length === 1 ? options[0] : null;
|
|
50
|
+
|
|
51
|
+
const runOption = async (opt: ShareOption) => {
|
|
52
|
+
setOpen(false);
|
|
53
|
+
setStatus("busy");
|
|
54
|
+
setError(null);
|
|
55
|
+
try {
|
|
56
|
+
await opt.run();
|
|
57
|
+
setStatus("done");
|
|
58
|
+
setTimeout(() => setStatus("idle"), 1200);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error("[share-menu]", err);
|
|
61
|
+
setError(err instanceof Error ? err.message : "Share failed");
|
|
62
|
+
setStatus("error");
|
|
63
|
+
setTimeout(() => setStatus("idle"), 3000);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleTrigger = () => {
|
|
68
|
+
if (status === "busy") return;
|
|
69
|
+
if (single) {
|
|
70
|
+
void runOption(single);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setOpen((v) => !v);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const icon =
|
|
77
|
+
status === "busy" ? (
|
|
78
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
79
|
+
) : status === "done" ? (
|
|
80
|
+
<Check className="h-3.5 w-3.5" />
|
|
81
|
+
) : status === "error" ? (
|
|
82
|
+
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
|
83
|
+
) : (
|
|
84
|
+
<Share2 className="h-3.5 w-3.5" />
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div ref={wrapperRef} className={cn("relative inline-flex", className)}>
|
|
89
|
+
{renderTrigger ? (
|
|
90
|
+
renderTrigger({ onClick: handleTrigger, busy: status === "busy" })
|
|
91
|
+
) : (
|
|
92
|
+
<button
|
|
93
|
+
onClick={handleTrigger}
|
|
94
|
+
disabled={status === "busy"}
|
|
95
|
+
title={error ?? title}
|
|
96
|
+
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground disabled:opacity-50"
|
|
97
|
+
>
|
|
98
|
+
{icon}
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
{open && !single && (
|
|
102
|
+
<div
|
|
103
|
+
role="menu"
|
|
104
|
+
className="absolute right-0 top-full z-50 mt-1 min-w-[12rem] overflow-hidden rounded-lg border border-border bg-background py-1 shadow-lg"
|
|
105
|
+
>
|
|
106
|
+
{options.map((opt) => (
|
|
107
|
+
<button
|
|
108
|
+
key={opt.id}
|
|
109
|
+
role="menuitem"
|
|
110
|
+
onClick={() => void runOption(opt)}
|
|
111
|
+
className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-surface-raised"
|
|
112
|
+
>
|
|
113
|
+
<div className="font-medium">{opt.label}</div>
|
|
114
|
+
{opt.hint && <div className="text-[10px] text-muted-foreground">{opt.hint}</div>}
|
|
115
|
+
</button>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Mic, X } from "lucide-react";
|
|
3
|
+
import { useVoiceStore } from "../../voice/voice-store.js";
|
|
4
|
+
import { cn } from "../../lib/utils.js";
|
|
5
|
+
|
|
6
|
+
export function DictationSheet({
|
|
7
|
+
open,
|
|
8
|
+
onStop,
|
|
9
|
+
onCancel,
|
|
10
|
+
}: {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onStop: () => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}) {
|
|
15
|
+
const partial = useVoiceStore((s) => s.partial);
|
|
16
|
+
const finalText = useVoiceStore((s) => s.finalText);
|
|
17
|
+
const audioLevel = useVoiceStore((s) => s.audioLevel);
|
|
18
|
+
const error = useVoiceStore((s) => s.error);
|
|
19
|
+
const draining = useVoiceStore((s) => s.draining);
|
|
20
|
+
// Mic still opening — the session is being fetched, so capture isn't live
|
|
21
|
+
// yet. Drives the honest "Connecting…" state instead of claiming "Listening".
|
|
22
|
+
const connecting = useVoiceStore((s) => s.connecting);
|
|
23
|
+
const providerId = useVoiceStore((s) => s.providerId);
|
|
24
|
+
// Text already under review — the current capture appends to it on stop,
|
|
25
|
+
// so show it dimmed as the lead-in.
|
|
26
|
+
const reviewText = useVoiceStore((s) => s.reviewText);
|
|
27
|
+
const transcriptRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
|
|
29
|
+
const statusLabel = connecting
|
|
30
|
+
? "Connecting…"
|
|
31
|
+
: draining
|
|
32
|
+
? "Finalizing…"
|
|
33
|
+
: "Listening";
|
|
34
|
+
// Surface the active provider so a silent fallback to browser speech (which
|
|
35
|
+
// streams mic audio to Google on Chrome) isn't invisible.
|
|
36
|
+
const providerNote = providerNoteFor(providerId);
|
|
37
|
+
|
|
38
|
+
// Keep the newest words in view — the live transcript grows past the
|
|
39
|
+
// sheet's height quickly when dictating longer prompts.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const el = transcriptRef.current;
|
|
42
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
43
|
+
}, [finalText, partial, open]);
|
|
44
|
+
|
|
45
|
+
// Lock body scroll while open
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
const prev = document.body.style.overflow;
|
|
49
|
+
document.body.style.overflow = "hidden";
|
|
50
|
+
return () => {
|
|
51
|
+
document.body.style.overflow = prev;
|
|
52
|
+
};
|
|
53
|
+
}, [open]);
|
|
54
|
+
|
|
55
|
+
// Escape cancels
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return;
|
|
58
|
+
const h = (e: KeyboardEvent) => {
|
|
59
|
+
if (e.key === "Escape") onCancel();
|
|
60
|
+
};
|
|
61
|
+
document.addEventListener("keydown", h);
|
|
62
|
+
return () => document.removeEventListener("keydown", h);
|
|
63
|
+
}, [open, onCancel]);
|
|
64
|
+
|
|
65
|
+
if (!open) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{/* Backdrop — tap-to-stop is intentional: huge target. Disabled while
|
|
70
|
+
draining so a stray tap doesn't double-trigger. */}
|
|
71
|
+
<div
|
|
72
|
+
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
|
|
73
|
+
onClick={() => { if (!draining) onStop(); }}
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
{/* Sheet */}
|
|
77
|
+
<div
|
|
78
|
+
className={cn(
|
|
79
|
+
"fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-3xl border-t border-border bg-surface shadow-[0_-16px_48px_rgba(0,0,0,0.5)]",
|
|
80
|
+
"max-h-[60vh] min-h-[40vh]",
|
|
81
|
+
"animate-in slide-in-from-bottom duration-200"
|
|
82
|
+
)}
|
|
83
|
+
// Don't propagate to backdrop
|
|
84
|
+
onClick={(e) => e.stopPropagation()}
|
|
85
|
+
>
|
|
86
|
+
<div className="flex items-center justify-between border-b border-border/50 px-5 py-3">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<span className="relative inline-flex h-2 w-2">
|
|
89
|
+
{/* Pulse only once capture is actually live — a "connecting" dot
|
|
90
|
+
must not imply the mic is already open. */}
|
|
91
|
+
{!connecting && (
|
|
92
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
|
|
93
|
+
)}
|
|
94
|
+
<span
|
|
95
|
+
className={cn(
|
|
96
|
+
"relative inline-flex h-2 w-2 rounded-full",
|
|
97
|
+
connecting ? "bg-muted-foreground/50" : "bg-primary"
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
</span>
|
|
101
|
+
<span className="font-[family-name:var(--font-mono)] text-xs uppercase tracking-widest text-muted-foreground">
|
|
102
|
+
{statusLabel}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
<button
|
|
106
|
+
onClick={onCancel}
|
|
107
|
+
disabled={draining}
|
|
108
|
+
title="Cancel"
|
|
109
|
+
className="rounded-lg p-2 text-muted-foreground hover:bg-surface-raised hover:text-foreground disabled:opacity-30"
|
|
110
|
+
>
|
|
111
|
+
<X className="h-4 w-4" />
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Active-provider note — subtle for Deepgram, amber for the browser
|
|
116
|
+
speech fallback whose audio leaves the device. */}
|
|
117
|
+
{providerNote && !error && (
|
|
118
|
+
<div
|
|
119
|
+
className={cn(
|
|
120
|
+
"px-5 pt-2 text-center text-[11px]",
|
|
121
|
+
providerNote.warn
|
|
122
|
+
? "text-primary/90"
|
|
123
|
+
: "text-muted-foreground/50"
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{providerNote.text}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Waveform */}
|
|
131
|
+
<div className="flex items-center justify-center px-6 py-4">
|
|
132
|
+
<Waveform level={audioLevel} />
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Transcript */}
|
|
136
|
+
<div ref={transcriptRef} className="flex-1 overflow-y-auto px-6 pb-6">
|
|
137
|
+
{error ? (
|
|
138
|
+
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
|
|
139
|
+
{error}
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<p className="text-base leading-relaxed">
|
|
143
|
+
{reviewText && (
|
|
144
|
+
<span className="text-muted-foreground/50">{reviewText} </span>
|
|
145
|
+
)}
|
|
146
|
+
<span className="text-foreground">{finalText}</span>
|
|
147
|
+
{finalText && partial && " "}
|
|
148
|
+
<span className="text-muted-foreground/60">{partial}</span>
|
|
149
|
+
{!reviewText && !finalText && !partial && (
|
|
150
|
+
<span className="text-muted-foreground/40">
|
|
151
|
+
Speak to start...
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Big stop button — thumb-friendly */}
|
|
159
|
+
<div className="border-t border-border/50 p-4">
|
|
160
|
+
<button
|
|
161
|
+
onClick={onStop}
|
|
162
|
+
disabled={draining}
|
|
163
|
+
className="flex h-14 w-full items-center justify-center gap-3 rounded-xl bg-primary text-primary-foreground transition-all duration-150 hover:brightness-110 active:scale-[0.99] disabled:opacity-60 disabled:cursor-progress"
|
|
164
|
+
>
|
|
165
|
+
<Mic className="h-5 w-5" />
|
|
166
|
+
<span className="text-sm font-semibold uppercase tracking-wider">
|
|
167
|
+
{draining ? "Finalizing…" : "Done"}
|
|
168
|
+
</span>
|
|
169
|
+
</button>
|
|
170
|
+
<p className="mt-2 text-center text-[11px] text-muted-foreground/50">
|
|
171
|
+
{draining
|
|
172
|
+
? "Waiting for the last words…"
|
|
173
|
+
: "Tap anywhere outside to stop"}
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Human-readable note for the active speech provider, or null if unknown. */
|
|
182
|
+
function providerNoteFor(
|
|
183
|
+
providerId: string | null
|
|
184
|
+
): { text: string; warn: boolean } | null {
|
|
185
|
+
switch (providerId) {
|
|
186
|
+
case "deepgram":
|
|
187
|
+
return { text: "via Deepgram", warn: false };
|
|
188
|
+
case "webspeech":
|
|
189
|
+
// Browser speech (Chrome) streams mic audio to Google — call it out.
|
|
190
|
+
return { text: "via browser speech · audio goes to Google", warn: true };
|
|
191
|
+
case null:
|
|
192
|
+
return null;
|
|
193
|
+
default:
|
|
194
|
+
return { text: `via ${providerId}`, warn: false };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function Waveform({ level }: { level: number }) {
|
|
199
|
+
// Render 16 bars whose height is driven by current level + per-bar phase.
|
|
200
|
+
const bars = 16;
|
|
201
|
+
return (
|
|
202
|
+
<div className="flex h-16 items-center gap-1.5">
|
|
203
|
+
{Array.from({ length: bars }).map((_, i) => {
|
|
204
|
+
const phase = (i / bars) * Math.PI;
|
|
205
|
+
const offset = (Math.sin(phase + Date.now() / 200) + 1) / 2;
|
|
206
|
+
const h = 8 + level * 56 * (0.4 + 0.6 * offset);
|
|
207
|
+
return (
|
|
208
|
+
<span
|
|
209
|
+
key={i}
|
|
210
|
+
style={{ height: `${h}px` }}
|
|
211
|
+
className="w-1.5 rounded-full bg-primary transition-[height] duration-75 ease-linear"
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Mic } from "lucide-react";
|
|
2
|
+
import { cn } from "../../lib/utils.js";
|
|
3
|
+
|
|
4
|
+
export function MicButton({
|
|
5
|
+
active,
|
|
6
|
+
disabled,
|
|
7
|
+
onTap,
|
|
8
|
+
}: {
|
|
9
|
+
active: boolean;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
onTap: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={onTap}
|
|
17
|
+
disabled={disabled}
|
|
18
|
+
title={active ? "Stop dictation" : "Voice dictation"}
|
|
19
|
+
aria-label={active ? "Stop dictation" : "Voice dictation"}
|
|
20
|
+
className={cn(
|
|
21
|
+
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
|
22
|
+
active
|
|
23
|
+
? "bg-primary text-primary-foreground animate-pulse"
|
|
24
|
+
: "text-muted-foreground hover:bg-surface-raised hover:text-foreground",
|
|
25
|
+
disabled && "opacity-30 cursor-not-allowed"
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
<Mic className="h-4 w-4" />
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|