shopify-chatbot-widget 0.9.7 → 0.9.8
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/jaweb-chatbot.css +1 -1
- package/dist/jaweb-chatbot.iife.js +380 -133
- package/package.json +1 -1
- package/src/Chatbot/Chatbot.css +82 -171
- package/src/Chatbot/components/ChatInput.css +426 -114
- package/src/Chatbot/components/ChatInput.tsx +313 -219
- package/src/Chatbot/components/Messages/ChatMessages.css +17 -1
- package/src/Chatbot/components/Messages/ChatMessages.tsx +161 -192
- package/src/Chatbot/components/Messages/Typing.css +114 -11
- package/src/Chatbot/components/Messages/Typing.tsx +22 -8
- package/src/Chatbot/components/Messenger/Messenger.tsx +26 -27
- package/src/Chatbot/components/Sessions/RenderList.tsx +445 -141
- package/src/hooks/useChatbotAPI.tsx +2 -16
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from "react";
|
|
2
2
|
import type { ChangeEvent } from "react";
|
|
3
|
-
import { useTranslation } from "react-i18next";
|
|
4
|
-
import {
|
|
3
|
+
// import { useTranslation } from "react-i18next";
|
|
4
|
+
import { message as antMessage } from "antd";
|
|
5
5
|
import {
|
|
6
6
|
PictureOutlined,
|
|
7
|
-
SendOutlined,
|
|
8
7
|
CloseCircleOutlined,
|
|
9
8
|
AudioOutlined,
|
|
10
9
|
StopOutlined,
|
|
11
10
|
PlayCircleOutlined,
|
|
12
11
|
PauseCircleOutlined,
|
|
12
|
+
LoadingOutlined,
|
|
13
|
+
ArrowUpOutlined,
|
|
13
14
|
} from "@ant-design/icons";
|
|
14
15
|
import WaveSurfer from "wavesurfer.js";
|
|
16
|
+
import "./ChatInput.css";
|
|
15
17
|
|
|
16
18
|
interface ChatInputProps {
|
|
17
19
|
userMessage: string;
|
|
@@ -24,6 +26,7 @@ interface ChatInputProps {
|
|
|
24
26
|
audioBlob: Blob | null;
|
|
25
27
|
setAudioBlob: (blob: Blob | null) => void;
|
|
26
28
|
isSending: boolean;
|
|
29
|
+
colorCode: string;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const ChatInput: React.FC<ChatInputProps> = ({
|
|
@@ -37,35 +40,118 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
37
40
|
audioBlob,
|
|
38
41
|
setAudioBlob,
|
|
39
42
|
isSending,
|
|
43
|
+
colorCode,
|
|
40
44
|
}) => {
|
|
41
|
-
const { t } = useTranslation();
|
|
45
|
+
// const { t } = useTranslation();
|
|
42
46
|
const [isRecording, setIsRecording] = useState(false);
|
|
43
47
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
48
|
+
const [recordingDuration, setRecordingDuration] = useState(0);
|
|
49
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
44
50
|
|
|
45
51
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
46
52
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
47
53
|
const audioChunks = useRef<Blob[]>([]);
|
|
48
54
|
const waveformRef = useRef<HTMLDivElement | null>(null);
|
|
49
55
|
const wavesurfer = useRef<WaveSurfer | null>(null);
|
|
56
|
+
const recordingIntervalRef = useRef<number | null>(null);
|
|
57
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
58
|
+
|
|
59
|
+
// Helper function to adjust color brightness
|
|
60
|
+
const adjustBrightness = (hex: string, percent: number) => {
|
|
61
|
+
const num = parseInt(hex.replace("#", ""), 16);
|
|
62
|
+
const amt = Math.round(2.55 * percent);
|
|
63
|
+
const R = (num >> 16) + amt;
|
|
64
|
+
const G = ((num >> 8) & 0x00ff) + amt;
|
|
65
|
+
const B = (num & 0x0000ff) + amt;
|
|
66
|
+
return (
|
|
67
|
+
"#" +
|
|
68
|
+
(
|
|
69
|
+
0x1000000 +
|
|
70
|
+
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
|
71
|
+
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
|
72
|
+
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
|
73
|
+
)
|
|
74
|
+
.toString(16)
|
|
75
|
+
.slice(1)
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Format duration as MM:SS
|
|
80
|
+
const formatDuration = (seconds: number) => {
|
|
81
|
+
const mins = Math.floor(seconds / 60);
|
|
82
|
+
const secs = seconds % 60;
|
|
83
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Validate image file
|
|
87
|
+
const validateImage = (file: File): boolean => {
|
|
88
|
+
const maxSize = 5 * 1024 * 1024; // 5MB
|
|
89
|
+
if (file.size > maxSize) {
|
|
90
|
+
antMessage.warning("Image size should be less than 5MB");
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
};
|
|
50
95
|
|
|
51
96
|
const handleImageUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
|
52
97
|
const file = event.target.files?.[0];
|
|
53
|
-
if (file
|
|
98
|
+
if (file && validateImage(file)) {
|
|
99
|
+
setSelectedImage(file);
|
|
100
|
+
}
|
|
101
|
+
event.target.value = "";
|
|
54
102
|
};
|
|
55
103
|
|
|
56
104
|
const removeSelectedImage = () => {
|
|
57
105
|
setSelectedImage(null);
|
|
58
106
|
};
|
|
59
107
|
|
|
108
|
+
// Auto-resize textarea
|
|
109
|
+
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
110
|
+
setUserMessage(e.target.value);
|
|
111
|
+
|
|
112
|
+
const textarea = e.target;
|
|
113
|
+
textarea.style.height = "auto";
|
|
114
|
+
const newHeight = Math.min(textarea.scrollHeight, 67.5); // Changed from 200 to 67.5 (3 lines)
|
|
115
|
+
textarea.style.height = `${newHeight}px`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Drag and drop handlers
|
|
119
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
e.stopPropagation();
|
|
122
|
+
setIsDragging(true);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
setIsDragging(false);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
e.stopPropagation();
|
|
134
|
+
setIsDragging(false);
|
|
135
|
+
|
|
136
|
+
const file = e.dataTransfer.files?.[0];
|
|
137
|
+
if (file && file.type.startsWith("image/") && validateImage(file)) {
|
|
138
|
+
setSelectedImage(file);
|
|
139
|
+
} else if (file && !file.type.startsWith("image/")) {
|
|
140
|
+
antMessage.warning("Please drop an image file");
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
60
144
|
const handleVoiceRecord = async () => {
|
|
61
145
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
62
|
-
|
|
146
|
+
antMessage.error("Voice recording is not supported in your browser");
|
|
63
147
|
return;
|
|
64
148
|
}
|
|
65
149
|
|
|
66
150
|
if (!isRecording) {
|
|
67
151
|
try {
|
|
68
|
-
const stream = await navigator.mediaDevices.getUserMedia({
|
|
152
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
153
|
+
audio: true,
|
|
154
|
+
});
|
|
69
155
|
const mimeType = MediaRecorder.isTypeSupported("audio/webm")
|
|
70
156
|
? "audio/webm"
|
|
71
157
|
: "audio/mp4";
|
|
@@ -73,6 +159,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
73
159
|
const recorder = new MediaRecorder(stream, { mimeType });
|
|
74
160
|
mediaRecorderRef.current = recorder;
|
|
75
161
|
audioChunks.current = [];
|
|
162
|
+
setRecordingDuration(0);
|
|
163
|
+
|
|
164
|
+
recordingIntervalRef.current = setInterval(() => {
|
|
165
|
+
setRecordingDuration((prev) => prev + 1);
|
|
166
|
+
}, 1000);
|
|
76
167
|
|
|
77
168
|
recorder.ondataavailable = (event) => {
|
|
78
169
|
if (event.data.size > 0) audioChunks.current.push(event.data);
|
|
@@ -83,13 +174,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
83
174
|
setAudioBlob(blob);
|
|
84
175
|
setIsRecording(false);
|
|
85
176
|
stream.getTracks().forEach((track) => track.stop());
|
|
177
|
+
if (recordingIntervalRef.current) {
|
|
178
|
+
clearInterval(recordingIntervalRef.current);
|
|
179
|
+
}
|
|
86
180
|
};
|
|
87
181
|
|
|
88
182
|
recorder.start();
|
|
89
183
|
setIsRecording(true);
|
|
90
184
|
} catch (err) {
|
|
91
185
|
console.error(err);
|
|
92
|
-
|
|
186
|
+
antMessage.error("Microphone permission denied");
|
|
93
187
|
}
|
|
94
188
|
} else {
|
|
95
189
|
mediaRecorderRef.current?.stop();
|
|
@@ -103,20 +197,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
103
197
|
|
|
104
198
|
wavesurfer.current = WaveSurfer.create({
|
|
105
199
|
container: waveformRef.current,
|
|
106
|
-
waveColor: "#
|
|
107
|
-
progressColor:
|
|
108
|
-
cursorWidth:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
200
|
+
waveColor: "#d1d5db",
|
|
201
|
+
progressColor: colorCode,
|
|
202
|
+
cursorWidth: 2,
|
|
203
|
+
cursorColor: colorCode,
|
|
204
|
+
barWidth: 2,
|
|
205
|
+
barHeight: 1,
|
|
206
|
+
barGap: 3,
|
|
207
|
+
height: 32,
|
|
113
208
|
interact: true,
|
|
209
|
+
hideScrollbar: true,
|
|
114
210
|
});
|
|
115
211
|
|
|
116
212
|
wavesurfer.current.load(url);
|
|
117
213
|
wavesurfer.current.on("finish", () => setIsPlaying(false));
|
|
118
214
|
}
|
|
119
|
-
}, [audioBlob]);
|
|
215
|
+
}, [audioBlob, colorCode]);
|
|
120
216
|
|
|
121
217
|
const togglePlay = () => {
|
|
122
218
|
if (wavesurfer.current) {
|
|
@@ -128,232 +224,230 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|
|
128
224
|
const handleCancelRecording = () => {
|
|
129
225
|
setIsRecording(false);
|
|
130
226
|
setAudioBlob(null);
|
|
227
|
+
setRecordingDuration(0);
|
|
131
228
|
wavesurfer.current?.destroy();
|
|
229
|
+
if (recordingIntervalRef.current) {
|
|
230
|
+
clearInterval(recordingIntervalRef.current);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
return () => {
|
|
236
|
+
if (recordingIntervalRef.current) {
|
|
237
|
+
clearInterval(recordingIntervalRef.current);
|
|
238
|
+
}
|
|
239
|
+
wavesurfer.current?.destroy();
|
|
240
|
+
};
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
const hasContent = userMessage.trim() || selectedImage || audioBlob;
|
|
244
|
+
|
|
245
|
+
const hexToRgba = (hex: string, alpha: number) => {
|
|
246
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
247
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
248
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
249
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
132
250
|
};
|
|
133
251
|
|
|
134
252
|
return (
|
|
135
253
|
<div
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
height: "56px",
|
|
146
|
-
}}
|
|
254
|
+
className="chat-input-wrapper"
|
|
255
|
+
style={
|
|
256
|
+
{
|
|
257
|
+
"--primary-color": colorCode,
|
|
258
|
+
"--primary-hover": adjustBrightness(colorCode, -10),
|
|
259
|
+
"--primary-light": hexToRgba(colorCode, 0.1),
|
|
260
|
+
"--primary-shadow": hexToRgba(colorCode, 0.3),
|
|
261
|
+
} as React.CSSProperties
|
|
262
|
+
}
|
|
147
263
|
>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
border: "none",
|
|
153
|
-
margin: "0 0.5rem",
|
|
154
|
-
cursor: "pointer",
|
|
155
|
-
padding: "0"
|
|
156
|
-
}}
|
|
157
|
-
onClick={() => fileInputRef.current?.click()}
|
|
158
|
-
>
|
|
159
|
-
{selectedImage ? (
|
|
160
|
-
<div style={{ position: "relative" }}>
|
|
264
|
+
{/* Image Preview */}
|
|
265
|
+
{selectedImage && (
|
|
266
|
+
<div className="image-preview-container">
|
|
267
|
+
<div className="image-preview-content">
|
|
161
268
|
<img
|
|
162
269
|
src={URL.createObjectURL(selectedImage)}
|
|
163
|
-
alt=
|
|
164
|
-
|
|
165
|
-
width: "2rem",
|
|
166
|
-
height: "2rem",
|
|
167
|
-
borderRadius: "9999px",
|
|
168
|
-
objectFit: "cover",
|
|
169
|
-
padding: "0"
|
|
170
|
-
}}
|
|
171
|
-
/>
|
|
172
|
-
<CloseCircleOutlined
|
|
173
|
-
style={{
|
|
174
|
-
position: "absolute",
|
|
175
|
-
top: "-0.5rem",
|
|
176
|
-
right: "-0.5rem",
|
|
177
|
-
fontSize: "14px",
|
|
178
|
-
color: "#ef4444",
|
|
179
|
-
background: "white",
|
|
180
|
-
borderRadius: "9999px",
|
|
181
|
-
cursor: "pointer",
|
|
182
|
-
}}
|
|
183
|
-
onClick={(e) => {
|
|
184
|
-
e.stopPropagation();
|
|
185
|
-
removeSelectedImage();
|
|
186
|
-
}}
|
|
270
|
+
alt="Selected"
|
|
271
|
+
className="image-preview"
|
|
187
272
|
/>
|
|
273
|
+
<div className="image-preview-info">
|
|
274
|
+
<span className="image-preview-name">{selectedImage.name}</span>
|
|
275
|
+
<span className="image-preview-size">
|
|
276
|
+
{(selectedImage.size / 1024).toFixed(1)} KB
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
<button
|
|
280
|
+
className="image-preview-remove"
|
|
281
|
+
onClick={removeSelectedImage}
|
|
282
|
+
aria-label="Remove image"
|
|
283
|
+
>
|
|
284
|
+
<CloseCircleOutlined />
|
|
285
|
+
</button>
|
|
188
286
|
</div>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)}
|
|
192
|
-
</button>
|
|
193
|
-
|
|
194
|
-
<input
|
|
195
|
-
disabled={isSending}
|
|
196
|
-
type="file"
|
|
197
|
-
accept="image/*"
|
|
198
|
-
ref={fileInputRef}
|
|
199
|
-
onChange={handleImageUpload}
|
|
200
|
-
style={{ display: "none",padding: "0" }}
|
|
201
|
-
/>
|
|
202
|
-
|
|
203
|
-
<Tooltip title={isRecording ? "Stop Recording" : "Send Audio"}>
|
|
204
|
-
<button
|
|
205
|
-
onClick={handleVoiceRecord}
|
|
206
|
-
style={{
|
|
207
|
-
color: isRecording ? "#ef4444" : "#6b7280",
|
|
208
|
-
background: "none",
|
|
209
|
-
border: "none",
|
|
210
|
-
margin: "0 0.5rem",
|
|
211
|
-
cursor: "pointer",
|
|
212
|
-
padding: "0"
|
|
213
|
-
}}
|
|
214
|
-
>
|
|
215
|
-
{isRecording ? (
|
|
216
|
-
<StopOutlined style={{ fontSize: "20px" }} />
|
|
217
|
-
) : (
|
|
218
|
-
<AudioOutlined style={{ fontSize: "20px" }} />
|
|
219
|
-
)}
|
|
220
|
-
</button>
|
|
221
|
-
</Tooltip>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
222
289
|
|
|
290
|
+
{/* Main Input Container */}
|
|
223
291
|
<div
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
alignItems: "center",
|
|
231
|
-
}}
|
|
292
|
+
className={`chat-input-container ${isSending ? "disabled" : ""} ${
|
|
293
|
+
isDragging ? "dragging" : ""
|
|
294
|
+
}`}
|
|
295
|
+
onDragOver={handleDragOver}
|
|
296
|
+
onDragLeave={handleDragLeave}
|
|
297
|
+
onDrop={handleDrop}
|
|
232
298
|
>
|
|
233
|
-
{
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
299
|
+
{/* Input Content Area */}
|
|
300
|
+
<div className="input-content">
|
|
301
|
+
{audioBlob ? (
|
|
302
|
+
// Audio Playback UI
|
|
303
|
+
<div className="audio-playback-container">
|
|
304
|
+
<button
|
|
305
|
+
className="audio-play-button"
|
|
306
|
+
onClick={togglePlay}
|
|
307
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
308
|
+
>
|
|
309
|
+
{isPlaying ? (
|
|
310
|
+
<PauseCircleOutlined className="play-icon" />
|
|
311
|
+
) : (
|
|
312
|
+
<PlayCircleOutlined className="play-icon" />
|
|
313
|
+
)}
|
|
314
|
+
</button>
|
|
315
|
+
<div ref={waveformRef} className="waveform" />
|
|
316
|
+
<button
|
|
317
|
+
className="audio-cancel-button"
|
|
318
|
+
onClick={handleCancelRecording}
|
|
319
|
+
aria-label="Cancel recording"
|
|
320
|
+
>
|
|
321
|
+
<CloseCircleOutlined />
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
) : isRecording ? (
|
|
325
|
+
// Recording UI
|
|
326
|
+
<div className="recording-container">
|
|
327
|
+
<div className="recording-indicator">
|
|
328
|
+
<span className="recording-dot"></span>
|
|
329
|
+
<span className="recording-text">Recording</span>
|
|
330
|
+
</div>
|
|
331
|
+
<span className="recording-duration">
|
|
332
|
+
{formatDuration(recordingDuration)}
|
|
333
|
+
</span>
|
|
334
|
+
</div>
|
|
335
|
+
) : (
|
|
336
|
+
// Text Input
|
|
337
|
+
<textarea
|
|
338
|
+
ref={textareaRef}
|
|
339
|
+
value={userMessage}
|
|
340
|
+
onChange={handleTextareaChange}
|
|
341
|
+
placeholder="Enter Message ..."
|
|
342
|
+
className="text-input"
|
|
343
|
+
disabled={isSending}
|
|
344
|
+
rows={1}
|
|
345
|
+
onKeyDown={(e) => {
|
|
346
|
+
if (e.key === "Enter" && !e.shiftKey && !isSending) {
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
|
|
349
|
+
const hasImage = !!selectedImage;
|
|
350
|
+
const hasText = !!userMessage.trim();
|
|
351
|
+
|
|
352
|
+
if (hasImage) {
|
|
353
|
+
handleSendImage(selectedImage, userMessage);
|
|
354
|
+
removeSelectedImage();
|
|
355
|
+
setUserMessage("");
|
|
356
|
+
if (textareaRef.current) {
|
|
357
|
+
textareaRef.current.style.height = "auto";
|
|
358
|
+
}
|
|
359
|
+
} else if (hasText) {
|
|
360
|
+
handleSendMessage();
|
|
361
|
+
setUserMessage("");
|
|
362
|
+
if (textareaRef.current) {
|
|
363
|
+
textareaRef.current.style.height = "auto";
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
252
367
|
}}
|
|
253
|
-
|
|
368
|
+
maxLength={2000}
|
|
369
|
+
/>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Bottom Action Bar */}
|
|
374
|
+
<div className="action-bar">
|
|
375
|
+
{/* Left side icons */}
|
|
376
|
+
<div className="action-icons-left">
|
|
377
|
+
<input
|
|
378
|
+
type="file"
|
|
379
|
+
accept="image/*"
|
|
380
|
+
ref={fileInputRef}
|
|
381
|
+
onChange={handleImageUpload}
|
|
382
|
+
style={{ display: "none" }}
|
|
383
|
+
/>
|
|
384
|
+
|
|
385
|
+
<button
|
|
386
|
+
className="action-icon-button"
|
|
387
|
+
onClick={() => fileInputRef.current?.click()}
|
|
388
|
+
disabled={isSending || isRecording}
|
|
389
|
+
aria-label="Upload image"
|
|
254
390
|
>
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
391
|
+
<PictureOutlined />
|
|
392
|
+
</button>
|
|
393
|
+
|
|
394
|
+
<button
|
|
395
|
+
onClick={handleVoiceRecord}
|
|
396
|
+
className={`action-icon-button ${isRecording ? "recording" : ""}`}
|
|
397
|
+
disabled={isSending}
|
|
398
|
+
aria-label={isRecording ? "Stop recording" : "Record voice"}
|
|
399
|
+
>
|
|
400
|
+
{isRecording ? <StopOutlined /> : <AudioOutlined />}
|
|
260
401
|
</button>
|
|
261
|
-
<div ref={waveformRef} style={{ width: "100%", height: "2rem" }} />
|
|
262
|
-
<CloseCircleOutlined
|
|
263
|
-
style={{
|
|
264
|
-
fontSize: "18px",
|
|
265
|
-
color: "#ef4444",
|
|
266
|
-
marginLeft: "0.75rem",
|
|
267
|
-
cursor: "pointer",
|
|
268
|
-
}}
|
|
269
|
-
onClick={handleCancelRecording}
|
|
270
|
-
/>
|
|
271
|
-
</div>
|
|
272
|
-
) : isRecording ? (
|
|
273
|
-
<div
|
|
274
|
-
style={{
|
|
275
|
-
display: "flex",
|
|
276
|
-
alignItems: "center",
|
|
277
|
-
justifyContent: "center",
|
|
278
|
-
backgroundColor: "#f3f4f6",
|
|
279
|
-
padding: "0.5rem 1rem",
|
|
280
|
-
borderRadius: "0.5rem",
|
|
281
|
-
animation: "pulse 2s infinite",
|
|
282
|
-
|
|
283
|
-
}}
|
|
284
|
-
>
|
|
285
|
-
<span>Recording</span>
|
|
286
402
|
</div>
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
removeSelectedImage();
|
|
312
|
-
setUserMessage("");
|
|
313
|
-
} else if (hasText) {
|
|
314
|
-
handleSendMessage();
|
|
315
|
-
setUserMessage("");
|
|
403
|
+
|
|
404
|
+
{/* Right side send button */}
|
|
405
|
+
<button
|
|
406
|
+
disabled={isSending || !hasContent}
|
|
407
|
+
onClick={() => {
|
|
408
|
+
const hasImage = !!selectedImage;
|
|
409
|
+
const hasText = !!userMessage.trim();
|
|
410
|
+
const hasAudio = !!audioBlob;
|
|
411
|
+
|
|
412
|
+
if (hasImage) {
|
|
413
|
+
handleSendImage(selectedImage, userMessage);
|
|
414
|
+
removeSelectedImage();
|
|
415
|
+
setUserMessage("");
|
|
416
|
+
if (textareaRef.current) {
|
|
417
|
+
textareaRef.current.style.height = "auto";
|
|
418
|
+
}
|
|
419
|
+
} else if (hasAudio) {
|
|
420
|
+
handleSendVoice(audioBlob);
|
|
421
|
+
handleCancelRecording();
|
|
422
|
+
} else if (hasText) {
|
|
423
|
+
handleSendMessage();
|
|
424
|
+
setUserMessage("");
|
|
425
|
+
if (textareaRef.current) {
|
|
426
|
+
textareaRef.current.style.height = "auto";
|
|
316
427
|
}
|
|
317
428
|
}
|
|
318
429
|
}}
|
|
319
|
-
|
|
320
|
-
|
|
430
|
+
className={`send-button ${hasContent && !isSending ? "active" : ""}`}
|
|
431
|
+
aria-label="Send message"
|
|
432
|
+
>
|
|
433
|
+
{isSending ? (
|
|
434
|
+
<LoadingOutlined className="send-icon spin" />
|
|
435
|
+
) : (
|
|
436
|
+
<ArrowUpOutlined className="send-icon" />
|
|
437
|
+
)}
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
{/* Drag overlay */}
|
|
442
|
+
{isDragging && (
|
|
443
|
+
<div className="drag-overlay">
|
|
444
|
+
<PictureOutlined className="drag-icon" />
|
|
445
|
+
<span>Drop image here</span>
|
|
446
|
+
</div>
|
|
321
447
|
)}
|
|
322
448
|
</div>
|
|
323
|
-
|
|
324
|
-
<button
|
|
325
|
-
disabled={isSending}
|
|
326
|
-
onClick={() => {
|
|
327
|
-
const hasImage = !!selectedImage;
|
|
328
|
-
const hasText = !!userMessage.trim();
|
|
329
|
-
const hasAudio = !!audioBlob;
|
|
330
|
-
|
|
331
|
-
if (hasImage) {
|
|
332
|
-
handleSendImage(selectedImage, userMessage);
|
|
333
|
-
removeSelectedImage();
|
|
334
|
-
setUserMessage("");
|
|
335
|
-
} else if (hasAudio) {
|
|
336
|
-
handleSendVoice(audioBlob);
|
|
337
|
-
handleCancelRecording();
|
|
338
|
-
} else if (hasText) {
|
|
339
|
-
handleSendMessage();
|
|
340
|
-
setUserMessage("");
|
|
341
|
-
}
|
|
342
|
-
}}
|
|
343
|
-
aria-label={t("chatInput.send")}
|
|
344
|
-
style={{
|
|
345
|
-
color: "#6b7280",
|
|
346
|
-
background: "none",
|
|
347
|
-
border: "none",
|
|
348
|
-
margin: "0 0.5rem",
|
|
349
|
-
cursor: "pointer",
|
|
350
|
-
padding: "0"
|
|
351
|
-
}}
|
|
352
|
-
>
|
|
353
|
-
<SendOutlined style={{ fontSize: "20px" }} />
|
|
354
|
-
</button>
|
|
355
449
|
</div>
|
|
356
450
|
);
|
|
357
451
|
};
|
|
358
452
|
|
|
359
|
-
export default ChatInput;
|
|
453
|
+
export default ChatInput;
|
|
@@ -28,4 +28,20 @@
|
|
|
28
28
|
opacity: 1;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@keyframes fadeSlideIn {
|
|
35
|
+
from {
|
|
36
|
+
opacity: 0;
|
|
37
|
+
transform: translateY(15px);
|
|
38
|
+
}
|
|
39
|
+
to {
|
|
40
|
+
opacity: 1;
|
|
41
|
+
transform: translateY(0);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.first-message {
|
|
46
|
+
animation: fadeSlideIn 0.5s ease-out;
|
|
47
|
+
}
|