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