scribe-widget 1.0.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 +230 -0
- package/dist/scribe-widget.es.js +64563 -0
- package/dist/scribe-widget.umd.js +80 -0
- package/index.html +205 -0
- package/package.json +26 -0
- package/src/App.tsx +114 -0
- package/src/components/ConfigState.tsx +68 -0
- package/src/components/ErrorState.tsx +20 -0
- package/src/components/FloatingPanel.tsx +83 -0
- package/src/components/Icons.tsx +43 -0
- package/src/components/IdleState.tsx +24 -0
- package/src/components/PermissionState.tsx +22 -0
- package/src/components/ProcessingState.tsx +8 -0
- package/src/components/RecordingState.tsx +48 -0
- package/src/components/ResultsState.tsx +27 -0
- package/src/hooks/useScribeSession.ts +227 -0
- package/src/index.tsx +123 -0
- package/src/styles/widget.css +495 -0
- package/src/types.ts +14 -0
- package/src/vite-env.d.ts +6 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +27 -0
package/index.html
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Eka Scribe Widget Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
body {
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 40px;
|
|
15
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
16
|
+
min-height: 100vh;
|
|
17
|
+
}
|
|
18
|
+
.container {
|
|
19
|
+
max-width: 800px;
|
|
20
|
+
margin: 0 auto;
|
|
21
|
+
}
|
|
22
|
+
h1 {
|
|
23
|
+
color: white;
|
|
24
|
+
margin-bottom: 16px;
|
|
25
|
+
}
|
|
26
|
+
p {
|
|
27
|
+
color: rgba(255, 255, 255, 0.9);
|
|
28
|
+
line-height: 1.6;
|
|
29
|
+
}
|
|
30
|
+
.card {
|
|
31
|
+
background: white;
|
|
32
|
+
border-radius: 12px;
|
|
33
|
+
padding: 24px;
|
|
34
|
+
margin-top: 24px;
|
|
35
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
|
36
|
+
}
|
|
37
|
+
.card h2 {
|
|
38
|
+
margin-top: 0;
|
|
39
|
+
color: #1f2937;
|
|
40
|
+
}
|
|
41
|
+
.code-block {
|
|
42
|
+
background: #1f2937;
|
|
43
|
+
color: #e5e7eb;
|
|
44
|
+
padding: 16px;
|
|
45
|
+
border-radius: 8px;
|
|
46
|
+
overflow-x: auto;
|
|
47
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
48
|
+
font-size: 14px;
|
|
49
|
+
line-height: 1.5;
|
|
50
|
+
}
|
|
51
|
+
.code-block .comment {
|
|
52
|
+
color: #6b7280;
|
|
53
|
+
}
|
|
54
|
+
.code-block .string {
|
|
55
|
+
color: #34d399;
|
|
56
|
+
}
|
|
57
|
+
.code-block .keyword {
|
|
58
|
+
color: #60a5fa;
|
|
59
|
+
}
|
|
60
|
+
.result-box {
|
|
61
|
+
background: #f9fafb;
|
|
62
|
+
border: 1px solid #e5e7eb;
|
|
63
|
+
border-radius: 8px;
|
|
64
|
+
padding: 16px;
|
|
65
|
+
margin-top: 16px;
|
|
66
|
+
min-height: 100px;
|
|
67
|
+
}
|
|
68
|
+
.result-box pre {
|
|
69
|
+
margin: 0;
|
|
70
|
+
white-space: pre-wrap;
|
|
71
|
+
word-break: break-word;
|
|
72
|
+
font-size: 13px;
|
|
73
|
+
}
|
|
74
|
+
.config-form {
|
|
75
|
+
display: flex;
|
|
76
|
+
flex-direction: column;
|
|
77
|
+
gap: 16px;
|
|
78
|
+
}
|
|
79
|
+
.form-group {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 4px;
|
|
83
|
+
}
|
|
84
|
+
.form-group label {
|
|
85
|
+
font-weight: 500;
|
|
86
|
+
color: #374151;
|
|
87
|
+
font-size: 14px;
|
|
88
|
+
}
|
|
89
|
+
.form-group input {
|
|
90
|
+
padding: 10px 12px;
|
|
91
|
+
border: 1px solid #d1d5db;
|
|
92
|
+
border-radius: 6px;
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
}
|
|
95
|
+
.form-group input:focus {
|
|
96
|
+
outline: none;
|
|
97
|
+
border-color: #2563eb;
|
|
98
|
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
|
99
|
+
}
|
|
100
|
+
.btn {
|
|
101
|
+
background: #2563eb;
|
|
102
|
+
color: white;
|
|
103
|
+
border: none;
|
|
104
|
+
padding: 12px 24px;
|
|
105
|
+
border-radius: 8px;
|
|
106
|
+
font-size: 14px;
|
|
107
|
+
font-weight: 500;
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
transition: background 0.2s;
|
|
110
|
+
}
|
|
111
|
+
.btn:hover {
|
|
112
|
+
background: #1d4ed8;
|
|
113
|
+
}
|
|
114
|
+
</style>
|
|
115
|
+
</head>
|
|
116
|
+
<body>
|
|
117
|
+
<div class="container">
|
|
118
|
+
<h1>Eka Scribe Widget Demo</h1>
|
|
119
|
+
<p>
|
|
120
|
+
This is a demo page for the Eka Scribe floating panel widget.
|
|
121
|
+
The widget appears in the bottom-right corner and allows you to record audio for medical transcription.
|
|
122
|
+
</p>
|
|
123
|
+
|
|
124
|
+
<div class="card">
|
|
125
|
+
<h2>Configuration</h2>
|
|
126
|
+
<div class="config-form">
|
|
127
|
+
<div class="form-group">
|
|
128
|
+
<label for="apiKey">API Key</label>
|
|
129
|
+
<input type="text" id="apiKey" placeholder="Enter your API key" value="">
|
|
130
|
+
</div>
|
|
131
|
+
<div class="form-group">
|
|
132
|
+
<label for="baseUrl">Base URL</label>
|
|
133
|
+
<input type="text" id="baseUrl" placeholder="https://api.example.com" value="">
|
|
134
|
+
</div>
|
|
135
|
+
<button class="btn" onclick="initWidget()">Initialize Widget</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div class="card">
|
|
140
|
+
<h2>Usage</h2>
|
|
141
|
+
<div class="code-block">
|
|
142
|
+
<span class="comment">// Method 1: Script tag</span>
|
|
143
|
+
<script src="scribe-widget.umd.js"></script>
|
|
144
|
+
<script>
|
|
145
|
+
<span class="keyword">const</span> widget = EkaScribe.init({
|
|
146
|
+
apiKey: <span class="string">'your-api-key'</span>,
|
|
147
|
+
baseUrl: <span class="string">'https://api.example.com'</span>,
|
|
148
|
+
templates: [<span class="string">'soap'</span>],
|
|
149
|
+
debug: <span class="keyword">true</span>,
|
|
150
|
+
onResult: (result) => {
|
|
151
|
+
console.log(<span class="string">'Result:'</span>, result);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<span class="comment">// Method 2: ES Module</span>
|
|
157
|
+
<span class="keyword">import</span> EkaScribe <span class="keyword">from</span> <span class="string">'eka-scribe-widget'</span>;
|
|
158
|
+
|
|
159
|
+
<span class="keyword">const</span> widget = EkaScribe.init({
|
|
160
|
+
apiKey: <span class="string">'your-api-key'</span>,
|
|
161
|
+
baseUrl: <span class="string">'https://api.example.com'</span>,
|
|
162
|
+
});
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div class="card">
|
|
167
|
+
<h2>Results</h2>
|
|
168
|
+
<p style="color: #6b7280; font-size: 14px;">Recording results will appear here:</p>
|
|
169
|
+
<div class="result-box">
|
|
170
|
+
<pre id="resultOutput">No results yet. Start a recording to see results.</pre>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<script type="module">
|
|
176
|
+
import EkaScribe from './src/index.tsx';
|
|
177
|
+
|
|
178
|
+
window.initWidget = function() {
|
|
179
|
+
const apiKey = document.getElementById('apiKey').value;
|
|
180
|
+
const baseUrl = document.getElementById('baseUrl').value;
|
|
181
|
+
|
|
182
|
+
if (!baseUrl) {
|
|
183
|
+
alert('Please enter both API Key and Base URL');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const widget = EkaScribe.init({
|
|
188
|
+
apiKey: apiKey,
|
|
189
|
+
baseUrl: baseUrl,
|
|
190
|
+
templates: ['eka_emr_template'],
|
|
191
|
+
languageHint: ['en-IN'],
|
|
192
|
+
debug: true,
|
|
193
|
+
onResult: (result) => {
|
|
194
|
+
document.getElementById('resultOutput').textContent = JSON.stringify(result, null, 2);
|
|
195
|
+
},
|
|
196
|
+
onError: (error) => {
|
|
197
|
+
document.getElementById('resultOutput').textContent = 'Error: ' + error.message;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.log('Widget initialized:', widget);
|
|
202
|
+
}
|
|
203
|
+
</script>
|
|
204
|
+
</body>
|
|
205
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scribe-widget",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Floating panel widget for medical transcription using eka.scribe",
|
|
5
|
+
"main": "dist/scribe-widget.umd.js",
|
|
6
|
+
"module": "dist/scribe-widget.es.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "vite",
|
|
10
|
+
"build": "vite build",
|
|
11
|
+
"preview": "vite preview",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"med-scribe-alliance-ts-sdk": "1.0.2",
|
|
16
|
+
"react": "^18.2.0",
|
|
17
|
+
"react-dom": "^18.2.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/react": "^18.2.0",
|
|
21
|
+
"@types/react-dom": "^18.2.0",
|
|
22
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
23
|
+
"typescript": "^5.7.3",
|
|
24
|
+
"vite": "^5.4.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
import { FloatingPanel } from './components/FloatingPanel';
|
|
3
|
+
import { ConfigState } from './components/ConfigState';
|
|
4
|
+
import { IdleState } from './components/IdleState';
|
|
5
|
+
import { PermissionState } from './components/PermissionState';
|
|
6
|
+
import { RecordingState } from './components/RecordingState';
|
|
7
|
+
import { ProcessingState } from './components/ProcessingState';
|
|
8
|
+
import { ResultsState } from './components/ResultsState';
|
|
9
|
+
import { ErrorState } from './components/ErrorState';
|
|
10
|
+
import { useScribeSession } from './hooks/useScribeSession';
|
|
11
|
+
import { ScribeWidgetConfig } from './types';
|
|
12
|
+
|
|
13
|
+
interface AppProps {
|
|
14
|
+
config: ScribeWidgetConfig;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function App({ config: initialConfig, onClose }: AppProps) {
|
|
19
|
+
const [isMinimized, setIsMinimized] = useState(false);
|
|
20
|
+
const [credentials, setCredentials] = useState<{ apiKey: string; baseUrl: string } | null>(
|
|
21
|
+
initialConfig.baseUrl ? { apiKey: initialConfig.apiKey, baseUrl: initialConfig.baseUrl } : null
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Merge initial config with user-provided credentials
|
|
25
|
+
const config = useMemo<ScribeWidgetConfig>(() => {
|
|
26
|
+
if (!credentials) {
|
|
27
|
+
return initialConfig;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
...initialConfig,
|
|
31
|
+
apiKey: credentials.apiKey,
|
|
32
|
+
baseUrl: credentials.baseUrl,
|
|
33
|
+
};
|
|
34
|
+
}, [initialConfig, credentials]);
|
|
35
|
+
|
|
36
|
+
const needsConfig = !credentials;
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
state,
|
|
40
|
+
elapsedTime,
|
|
41
|
+
result,
|
|
42
|
+
errorMessage,
|
|
43
|
+
startRecording,
|
|
44
|
+
pauseRecording,
|
|
45
|
+
resumeRecording,
|
|
46
|
+
stopRecording,
|
|
47
|
+
reset,
|
|
48
|
+
} = useScribeSession(config);
|
|
49
|
+
|
|
50
|
+
if (isMinimized) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleConfigSubmit = (apiKey: string, baseUrl: string) => {
|
|
55
|
+
setCredentials({ apiKey, baseUrl });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const renderContent = () => {
|
|
59
|
+
// Show config form if credentials not provided
|
|
60
|
+
if (needsConfig) {
|
|
61
|
+
return (
|
|
62
|
+
<ConfigState
|
|
63
|
+
onSubmit={handleConfigSubmit}
|
|
64
|
+
initialApiKey={initialConfig.apiKey || ''}
|
|
65
|
+
initialBaseUrl={initialConfig.baseUrl || ''}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
switch (state) {
|
|
71
|
+
case 'idle':
|
|
72
|
+
return <IdleState onStartRecording={startRecording} />;
|
|
73
|
+
|
|
74
|
+
case 'permission':
|
|
75
|
+
return <PermissionState onRequestPermission={startRecording} />;
|
|
76
|
+
|
|
77
|
+
case 'recording':
|
|
78
|
+
case 'paused':
|
|
79
|
+
return (
|
|
80
|
+
<RecordingState
|
|
81
|
+
elapsedTime={elapsedTime}
|
|
82
|
+
isPaused={state === 'paused'}
|
|
83
|
+
onPause={pauseRecording}
|
|
84
|
+
onResume={resumeRecording}
|
|
85
|
+
onStop={stopRecording}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
case 'processing':
|
|
90
|
+
return <ProcessingState />;
|
|
91
|
+
|
|
92
|
+
case 'results':
|
|
93
|
+
return result ? (
|
|
94
|
+
<ResultsState result={result} onNewRecording={reset} />
|
|
95
|
+
) : null;
|
|
96
|
+
|
|
97
|
+
case 'error':
|
|
98
|
+
return <ErrorState message={errorMessage} onRetry={reset} />;
|
|
99
|
+
|
|
100
|
+
default:
|
|
101
|
+
return <IdleState onStartRecording={startRecording} />;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<FloatingPanel
|
|
107
|
+
position={config.position}
|
|
108
|
+
onClose={onClose}
|
|
109
|
+
onMinimize={() => setIsMinimized(true)}
|
|
110
|
+
>
|
|
111
|
+
{renderContent()}
|
|
112
|
+
</FloatingPanel>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { LogoIcon } from './Icons';
|
|
3
|
+
|
|
4
|
+
interface ConfigStateProps {
|
|
5
|
+
onSubmit: (apiKey: string, baseUrl: string) => void;
|
|
6
|
+
initialApiKey?: string;
|
|
7
|
+
initialBaseUrl?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ConfigState({ onSubmit, initialApiKey = '', initialBaseUrl = '' }: ConfigStateProps) {
|
|
11
|
+
const [apiKey, setApiKey] = useState(initialApiKey);
|
|
12
|
+
const [baseUrl, setBaseUrl] = useState(initialBaseUrl);
|
|
13
|
+
const [error, setError] = useState('');
|
|
14
|
+
|
|
15
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
|
|
18
|
+
if (!baseUrl.trim()) {
|
|
19
|
+
setError('Base URL is required');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setError('');
|
|
24
|
+
onSubmit(apiKey.trim(), baseUrl.trim());
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="config-state">
|
|
29
|
+
<div className="logo">
|
|
30
|
+
<div className="logo-icon">
|
|
31
|
+
<LogoIcon />
|
|
32
|
+
</div>
|
|
33
|
+
<span className="logo-text">eka.scribe</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<form className="config-form" onSubmit={handleSubmit}>
|
|
37
|
+
<div className="form-group">
|
|
38
|
+
<label htmlFor="eka-api-key">API Key</label>
|
|
39
|
+
<input
|
|
40
|
+
id="eka-api-key"
|
|
41
|
+
type="text"
|
|
42
|
+
value={apiKey}
|
|
43
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
44
|
+
placeholder="Enter your API key (optional)"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div className="form-group">
|
|
49
|
+
<label htmlFor="eka-base-url">Base URL</label>
|
|
50
|
+
<input
|
|
51
|
+
id="eka-base-url"
|
|
52
|
+
type="text"
|
|
53
|
+
value={baseUrl}
|
|
54
|
+
onChange={(e) => setBaseUrl(e.target.value)}
|
|
55
|
+
placeholder="https://api.eka.care"
|
|
56
|
+
required
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{error && <p className="config-error">{error}</p>}
|
|
61
|
+
|
|
62
|
+
<button type="submit" className="config-submit-btn">
|
|
63
|
+
Continue
|
|
64
|
+
</button>
|
|
65
|
+
</form>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ErrorIcon } from './Icons';
|
|
2
|
+
|
|
3
|
+
interface ErrorStateProps {
|
|
4
|
+
message: string;
|
|
5
|
+
onRetry: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ErrorState({ message, onRetry }: ErrorStateProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="error-state">
|
|
11
|
+
<div className="error-icon">
|
|
12
|
+
<ErrorIcon />
|
|
13
|
+
</div>
|
|
14
|
+
<p className="error-message">{message}</p>
|
|
15
|
+
<button className="retry-btn" onClick={onRetry}>
|
|
16
|
+
Try Again
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useRef, useEffect, useCallback, ReactNode } from 'react';
|
|
2
|
+
import { CloseIcon, MinimizeIcon } from './Icons';
|
|
3
|
+
|
|
4
|
+
interface FloatingPanelProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
position?: { bottom?: number; right?: number; top?: number; left?: number };
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
onMinimize: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FloatingPanel({ children, position, onClose, onMinimize }: FloatingPanelProps) {
|
|
12
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
13
|
+
const isDraggingRef = useRef(false);
|
|
14
|
+
const startPosRef = useRef({ x: 0, y: 0, right: 20, bottom: 20 });
|
|
15
|
+
|
|
16
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
17
|
+
if ((e.target as HTMLElement).closest('button')) return;
|
|
18
|
+
|
|
19
|
+
isDraggingRef.current = true;
|
|
20
|
+
const panel = panelRef.current;
|
|
21
|
+
if (panel) {
|
|
22
|
+
startPosRef.current = {
|
|
23
|
+
x: e.clientX,
|
|
24
|
+
y: e.clientY,
|
|
25
|
+
right: parseInt(panel.style.right || '20'),
|
|
26
|
+
bottom: parseInt(panel.style.bottom || '20'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
33
|
+
if (!isDraggingRef.current || !panelRef.current) return;
|
|
34
|
+
|
|
35
|
+
const deltaX = startPosRef.current.x - e.clientX;
|
|
36
|
+
const deltaY = startPosRef.current.y - e.clientY;
|
|
37
|
+
|
|
38
|
+
panelRef.current.style.right = `${startPosRef.current.right + deltaX}px`;
|
|
39
|
+
panelRef.current.style.bottom = `${startPosRef.current.bottom + deltaY}px`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleMouseUp = () => {
|
|
43
|
+
isDraggingRef.current = false;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
47
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
51
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const panelStyle: React.CSSProperties = {
|
|
56
|
+
bottom: position?.bottom ?? 20,
|
|
57
|
+
right: position?.right ?? 20,
|
|
58
|
+
...(position?.top !== undefined && { top: position.top, bottom: 'auto' }),
|
|
59
|
+
...(position?.left !== undefined && { left: position.left, right: 'auto' }),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div ref={panelRef} className="scribe-panel" style={panelStyle}>
|
|
64
|
+
<div className="panel-header" onMouseDown={handleMouseDown}>
|
|
65
|
+
<div className="drag-handle">
|
|
66
|
+
<span /><span /><span />
|
|
67
|
+
<span /><span /><span />
|
|
68
|
+
</div>
|
|
69
|
+
<div className="header-actions">
|
|
70
|
+
<button className="header-btn" onClick={onMinimize} title="Minimize">
|
|
71
|
+
<MinimizeIcon />
|
|
72
|
+
</button>
|
|
73
|
+
<button className="header-btn" onClick={onClose} title="Close">
|
|
74
|
+
<CloseIcon />
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="panel-content">
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const LogoIcon = () => (
|
|
2
|
+
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
3
|
+
<path d="M16 4C9.37 4 4 9.37 4 16s5.37 12 12 12 12-5.37 12-12S22.63 4 16 4z" fill="#2563eb"/>
|
|
4
|
+
<path d="M16 8c-2.2 0-4 1.8-4 4v4c0 2.2 1.8 4 4 4s4-1.8 4-4v-4c0-2.2-1.8-4-4-4z" fill="white"/>
|
|
5
|
+
<path d="M22 16c0 3.3-2.7 6-6 6s-6-2.7-6-6H8c0 4.1 3.1 7.5 7 7.9V26h2v-2.1c3.9-.4 7-3.8 7-7.9h-2z" fill="white"/>
|
|
6
|
+
</svg>
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export const MicIcon = () => (
|
|
10
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
11
|
+
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
|
|
12
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
13
|
+
<line x1="12" x2="12" y1="19" y2="22"/>
|
|
14
|
+
</svg>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const CloseIcon = () => (
|
|
18
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
19
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
20
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const MinimizeIcon = () => (
|
|
25
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
26
|
+
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
27
|
+
<path d="M9 3v18"/>
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const ErrorIcon = () => (
|
|
32
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
33
|
+
<circle cx="12" cy="12" r="10"/>
|
|
34
|
+
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
35
|
+
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const ChatIcon = () => (
|
|
40
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
41
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
42
|
+
</svg>
|
|
43
|
+
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { LogoIcon, MicIcon } from './Icons';
|
|
2
|
+
|
|
3
|
+
interface IdleStateProps {
|
|
4
|
+
onStartRecording: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function IdleState({ onStartRecording }: IdleStateProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="idle-state">
|
|
10
|
+
<div className="logo">
|
|
11
|
+
<div className="logo-icon">
|
|
12
|
+
<LogoIcon />
|
|
13
|
+
</div>
|
|
14
|
+
<span className="logo-text">eka.scribe</span>
|
|
15
|
+
</div>
|
|
16
|
+
<button className="start-btn" onClick={onStartRecording}>
|
|
17
|
+
Start Recording
|
|
18
|
+
<span className="mic-icon">
|
|
19
|
+
<MicIcon />
|
|
20
|
+
</span>
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MicIcon } from './Icons';
|
|
2
|
+
|
|
3
|
+
interface PermissionStateProps {
|
|
4
|
+
onRequestPermission: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function PermissionState({ onRequestPermission }: PermissionStateProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="permission-state">
|
|
10
|
+
<div className="permission-icon">
|
|
11
|
+
<MicIcon />
|
|
12
|
+
</div>
|
|
13
|
+
<p className="permission-text">
|
|
14
|
+
Microphone access is required to record audio.<br />
|
|
15
|
+
Please allow access when prompted.
|
|
16
|
+
</p>
|
|
17
|
+
<button className="permission-btn" onClick={onRequestPermission}>
|
|
18
|
+
Allow Microphone
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ChatIcon } from './Icons';
|
|
2
|
+
|
|
3
|
+
interface RecordingStateProps {
|
|
4
|
+
elapsedTime: number;
|
|
5
|
+
isPaused: boolean;
|
|
6
|
+
onPause: () => void;
|
|
7
|
+
onResume: () => void;
|
|
8
|
+
onStop: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatTime(seconds: number): string {
|
|
12
|
+
const mins = Math.floor(seconds / 60);
|
|
13
|
+
const secs = seconds % 60;
|
|
14
|
+
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function RecordingState({
|
|
18
|
+
elapsedTime,
|
|
19
|
+
isPaused,
|
|
20
|
+
onPause,
|
|
21
|
+
onResume,
|
|
22
|
+
onStop
|
|
23
|
+
}: RecordingStateProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="recording-state">
|
|
26
|
+
<div className="recording-indicator">
|
|
27
|
+
<div className={`recording-icon ${!isPaused ? 'active' : ''}`}>
|
|
28
|
+
<ChatIcon />
|
|
29
|
+
</div>
|
|
30
|
+
<span className="timer">{formatTime(elapsedTime)}</span>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="recording-actions">
|
|
33
|
+
{isPaused ? (
|
|
34
|
+
<button className="resume-btn" onClick={onResume}>
|
|
35
|
+
Resume
|
|
36
|
+
</button>
|
|
37
|
+
) : (
|
|
38
|
+
<button className="pause-btn" onClick={onPause}>
|
|
39
|
+
Pause
|
|
40
|
+
</button>
|
|
41
|
+
)}
|
|
42
|
+
<button className="stop-btn" onClick={onStop} title="Stop recording">
|
|
43
|
+
<div className="stop-icon" />
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|