blob: 76c25310cb569767e5247c44cca83895a41df433 [file] [log] [blame]
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import { createRef, ref } from "lit/directives/ref.js";
4
5@customElement("mobile-chat-input")
6export class MobileChatInput extends LitElement {
7 @property({ type: Boolean })
8 disabled = false;
9
10 @state()
11 private inputValue = "";
12
Philip Zeyliger73757082025-06-07 00:06:27 +000013 @state()
14 private uploadsInProgress = 0;
15
16 @state()
17 private showUploadProgress = false;
18
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070019 private textareaRef = createRef<HTMLTextAreaElement>();
20
21 static styles = css`
22 :host {
23 display: block;
24 background-color: #ffffff;
25 border-top: 1px solid #e9ecef;
26 padding: 12px 16px;
27 /* Enhanced iOS safe area support */
28 padding-bottom: max(12px, env(safe-area-inset-bottom));
29 padding-left: max(16px, env(safe-area-inset-left));
30 padding-right: max(16px, env(safe-area-inset-right));
31 /* Prevent iOS Safari from covering the input */
32 position: relative;
33 z-index: 1000;
34 }
35
36 .input-container {
37 display: flex;
38 align-items: flex-end;
39 gap: 12px;
40 max-width: 100%;
41 }
42
43 .input-wrapper {
44 flex: 1;
45 position: relative;
46 min-width: 0;
47 }
48
49 textarea {
50 width: 100%;
51 min-height: 40px;
52 max-height: 120px;
53 padding: 12px 16px;
54 border: 1px solid #ddd;
55 border-radius: 20px;
56 font-size: 16px;
57 font-family: inherit;
58 line-height: 1.4;
59 resize: none;
60 outline: none;
61 background-color: #f8f9fa;
62 transition:
63 border-color 0.2s,
64 background-color 0.2s;
65 box-sizing: border-box;
66 }
67
68 textarea:focus {
69 border-color: #007bff;
70 background-color: #ffffff;
71 }
72
73 textarea:disabled {
74 background-color: #e9ecef;
75 color: #6c757d;
76 cursor: not-allowed;
77 }
78
79 textarea::placeholder {
80 color: #6c757d;
81 }
82
83 .send-button {
84 flex-shrink: 0;
85 width: 40px;
86 height: 40px;
87 border: none;
88 border-radius: 50%;
89 background-color: #007bff;
90 color: white;
91 cursor: pointer;
92 display: flex;
93 align-items: center;
94 justify-content: center;
95 font-size: 18px;
96 transition:
97 background-color 0.2s,
98 transform 0.1s;
99 outline: none;
100 }
101
102 .send-button:hover:not(:disabled) {
103 background-color: #0056b3;
104 }
105
106 .send-button:active:not(:disabled) {
107 transform: scale(0.95);
108 }
109
110 .send-button:disabled {
111 background-color: #6c757d;
112 cursor: not-allowed;
113 opacity: 0.6;
114 }
115
116 .send-icon {
117 width: 16px;
118 height: 16px;
119 fill: currentColor;
120 }
121
122 /* iOS specific adjustments */
123 @supports (-webkit-touch-callout: none) {
124 textarea {
125 font-size: 16px; /* Prevent zoom on iOS */
126 }
127 }
Philip Zeyliger73757082025-06-07 00:06:27 +0000128
129 /* Upload progress indicator */
130 .upload-progress {
131 position: absolute;
132 top: -30px;
133 left: 50%;
134 transform: translateX(-50%);
135 background-color: #fff9c4;
136 color: #856404;
137 padding: 4px 8px;
138 border-radius: 4px;
139 font-size: 12px;
140 white-space: nowrap;
141 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
142 z-index: 1000;
143 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700144 `;
145
146 private handleInput = (e: Event) => {
147 const target = e.target as HTMLTextAreaElement;
148 this.inputValue = target.value;
149 this.adjustTextareaHeight();
150 };
151
Philip Zeyliger73757082025-06-07 00:06:27 +0000152 private handlePaste = async (e: ClipboardEvent) => {
153 // Check if the clipboard contains files
154 if (e.clipboardData && e.clipboardData.files.length > 0) {
155 const file = e.clipboardData.files[0];
156
157 // Handle the file upload
158 e.preventDefault(); // Prevent default paste behavior
159
160 // Get the current cursor position
161 const textarea = this.textareaRef.value;
162 const cursorPos = textarea ? textarea.selectionStart : this.inputValue.length;
163 await this.uploadFile(file, cursorPos);
164 }
165 };
166
167 // Utility function to handle file uploads
168 private async uploadFile(file: File, insertPosition: number) {
169 // Insert a placeholder at the cursor position
170 const textBefore = this.inputValue.substring(0, insertPosition);
171 const textAfter = this.inputValue.substring(insertPosition);
172
173 // Add a loading indicator
174 const loadingText = `[🔄 Uploading ${file.name}...]`;
175 this.inputValue = `${textBefore}${loadingText}${textAfter}`;
176
177 // Increment uploads in progress counter
178 this.uploadsInProgress++;
179 this.showUploadProgress = true;
180
181 // Adjust spacing immediately to show loading indicator
182 this.adjustTextareaHeight();
183
184 try {
185 // Create a FormData object to send the file
186 const formData = new FormData();
187 formData.append("file", file);
188
189 // Upload the file to the server using a relative path
190 const response = await fetch("./upload", {
191 method: "POST",
192 body: formData,
193 });
194
195 if (!response.ok) {
196 throw new Error(`Upload failed: ${response.statusText}`);
197 }
198
199 const data = await response.json();
200
201 // Replace the loading placeholder with the actual file path
202 this.inputValue = this.inputValue.replace(loadingText, `[${data.path}]`);
203
204 return data.path;
205 } catch (error) {
206 console.error("Failed to upload file:", error);
207
208 // Replace loading indicator with error message
209 const errorText = `[Upload failed: ${error.message}]`;
210 this.inputValue = this.inputValue.replace(loadingText, errorText);
211
212 throw error;
213 } finally {
214 // Always decrement the counter, even if there was an error
215 this.uploadsInProgress--;
216 if (this.uploadsInProgress === 0) {
217 this.showUploadProgress = false;
218 }
219 this.adjustTextareaHeight();
220 if (this.textareaRef.value) {
221 this.textareaRef.value.focus();
222 }
223 }
224 }
225
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700226 private handleKeyDown = (e: KeyboardEvent) => {
227 if (e.key === "Enter" && !e.shiftKey) {
228 e.preventDefault();
229 this.sendMessage();
230 }
231 };
232
233 private adjustTextareaHeight() {
234 if (this.textareaRef.value) {
235 const textarea = this.textareaRef.value;
236 textarea.style.height = "auto";
237 textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
238 }
239 }
240
241 private sendMessage = () => {
Philip Zeyliger73757082025-06-07 00:06:27 +0000242 // Prevent sending if there are uploads in progress
243 if (this.uploadsInProgress > 0) {
244 console.log(`Message send prevented: ${this.uploadsInProgress} uploads in progress`);
245 return;
246 }
247
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700248 const message = this.inputValue.trim();
249 if (message && !this.disabled) {
250 this.dispatchEvent(
251 new CustomEvent("send-message", {
252 detail: { message },
253 bubbles: true,
254 composed: true,
255 }),
256 );
257
258 this.inputValue = "";
259 if (this.textareaRef.value) {
260 this.textareaRef.value.value = "";
261 this.adjustTextareaHeight();
262 this.textareaRef.value.focus();
263 }
264 }
265 };
266
267 updated(changedProperties: Map<string, any>) {
268 super.updated(changedProperties);
269 this.adjustTextareaHeight();
Philip Zeyliger73757082025-06-07 00:06:27 +0000270
271 // Add paste event listener when component updates
272 if (this.textareaRef.value && !this.textareaRef.value.onpaste) {
273 this.textareaRef.value.addEventListener('paste', this.handlePaste);
274 }
275 }
276
277 disconnectedCallback() {
278 super.disconnectedCallback();
279 // Clean up paste event listener
280 if (this.textareaRef.value) {
281 this.textareaRef.value.removeEventListener('paste', this.handlePaste);
282 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700283 }
284
285 render() {
Philip Zeyliger73757082025-06-07 00:06:27 +0000286 const canSend = this.inputValue.trim().length > 0 && !this.disabled && this.uploadsInProgress === 0;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700287
288 return html`
289 <div class="input-container">
290 <div class="input-wrapper">
291 <textarea
292 ${ref(this.textareaRef)}
293 .value=${this.inputValue}
294 @input=${this.handleInput}
295 @keydown=${this.handleKeyDown}
296 placeholder="Message Sketch..."
Philip Zeyliger73757082025-06-07 00:06:27 +0000297 ?disabled=${this.disabled || this.uploadsInProgress > 0}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700298 rows="1"
299 ></textarea>
Philip Zeyliger73757082025-06-07 00:06:27 +0000300
301 ${this.showUploadProgress
302 ? html`
303 <div class="upload-progress">
304 Uploading ${this.uploadsInProgress} file${this.uploadsInProgress > 1 ? 's' : ''}...
305 </div>
306 `
307 : ''}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700308 </div>
309
310 <button
311 class="send-button"
312 @click=${this.sendMessage}
313 ?disabled=${!canSend}
Philip Zeyliger73757082025-06-07 00:06:27 +0000314 title=${this.uploadsInProgress > 0 ? 'Please wait for upload to complete' : 'Send message'}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700315 >
Philip Zeyliger73757082025-06-07 00:06:27 +0000316 ${this.uploadsInProgress > 0
317 ? html`<span style="font-size: 12px;">⏳</span>`
318 : html`<svg class="send-icon" viewBox="0 0 24 24">
319 <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
320 </svg>`
321 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700322 </button>
323 </div>
324 `;
325 }
326}