blob: 94abdbc1a7cbc0de01247df0771ff2c28306fa4f [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];
Autoformatterf825e692025-06-07 04:19:43 +0000156
Philip Zeyliger73757082025-06-07 00:06:27 +0000157 // Handle the file upload
158 e.preventDefault(); // Prevent default paste behavior
Autoformatterf825e692025-06-07 04:19:43 +0000159
Philip Zeyliger73757082025-06-07 00:06:27 +0000160 // Get the current cursor position
161 const textarea = this.textareaRef.value;
Autoformatterf825e692025-06-07 04:19:43 +0000162 const cursorPos = textarea
163 ? textarea.selectionStart
164 : this.inputValue.length;
Philip Zeyliger73757082025-06-07 00:06:27 +0000165 await this.uploadFile(file, cursorPos);
166 }
167 };
168
169 // Utility function to handle file uploads
170 private async uploadFile(file: File, insertPosition: number) {
171 // Insert a placeholder at the cursor position
172 const textBefore = this.inputValue.substring(0, insertPosition);
173 const textAfter = this.inputValue.substring(insertPosition);
174
175 // Add a loading indicator
176 const loadingText = `[🔄 Uploading ${file.name}...]`;
177 this.inputValue = `${textBefore}${loadingText}${textAfter}`;
178
179 // Increment uploads in progress counter
180 this.uploadsInProgress++;
181 this.showUploadProgress = true;
182
183 // Adjust spacing immediately to show loading indicator
184 this.adjustTextareaHeight();
185
186 try {
187 // Create a FormData object to send the file
188 const formData = new FormData();
189 formData.append("file", file);
190
191 // Upload the file to the server using a relative path
192 const response = await fetch("./upload", {
193 method: "POST",
194 body: formData,
195 });
196
197 if (!response.ok) {
198 throw new Error(`Upload failed: ${response.statusText}`);
199 }
200
201 const data = await response.json();
202
203 // Replace the loading placeholder with the actual file path
204 this.inputValue = this.inputValue.replace(loadingText, `[${data.path}]`);
205
206 return data.path;
207 } catch (error) {
208 console.error("Failed to upload file:", error);
209
210 // Replace loading indicator with error message
211 const errorText = `[Upload failed: ${error.message}]`;
212 this.inputValue = this.inputValue.replace(loadingText, errorText);
213
214 throw error;
215 } finally {
216 // Always decrement the counter, even if there was an error
217 this.uploadsInProgress--;
218 if (this.uploadsInProgress === 0) {
219 this.showUploadProgress = false;
220 }
221 this.adjustTextareaHeight();
222 if (this.textareaRef.value) {
223 this.textareaRef.value.focus();
224 }
225 }
226 }
227
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700228 private handleKeyDown = (e: KeyboardEvent) => {
229 if (e.key === "Enter" && !e.shiftKey) {
230 e.preventDefault();
231 this.sendMessage();
232 }
233 };
234
235 private adjustTextareaHeight() {
236 if (this.textareaRef.value) {
237 const textarea = this.textareaRef.value;
238 textarea.style.height = "auto";
239 textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
240 }
241 }
242
243 private sendMessage = () => {
Philip Zeyliger73757082025-06-07 00:06:27 +0000244 // Prevent sending if there are uploads in progress
245 if (this.uploadsInProgress > 0) {
Autoformatterf825e692025-06-07 04:19:43 +0000246 console.log(
247 `Message send prevented: ${this.uploadsInProgress} uploads in progress`,
248 );
Philip Zeyliger73757082025-06-07 00:06:27 +0000249 return;
250 }
251
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700252 const message = this.inputValue.trim();
253 if (message && !this.disabled) {
254 this.dispatchEvent(
255 new CustomEvent("send-message", {
256 detail: { message },
257 bubbles: true,
258 composed: true,
259 }),
260 );
261
262 this.inputValue = "";
263 if (this.textareaRef.value) {
264 this.textareaRef.value.value = "";
265 this.adjustTextareaHeight();
266 this.textareaRef.value.focus();
267 }
268 }
269 };
270
271 updated(changedProperties: Map<string, any>) {
272 super.updated(changedProperties);
273 this.adjustTextareaHeight();
Autoformatterf825e692025-06-07 04:19:43 +0000274
Philip Zeyliger73757082025-06-07 00:06:27 +0000275 // Add paste event listener when component updates
276 if (this.textareaRef.value && !this.textareaRef.value.onpaste) {
Autoformatterf825e692025-06-07 04:19:43 +0000277 this.textareaRef.value.addEventListener("paste", this.handlePaste);
Philip Zeyliger73757082025-06-07 00:06:27 +0000278 }
279 }
280
281 disconnectedCallback() {
282 super.disconnectedCallback();
283 // Clean up paste event listener
284 if (this.textareaRef.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000285 this.textareaRef.value.removeEventListener("paste", this.handlePaste);
Philip Zeyliger73757082025-06-07 00:06:27 +0000286 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700287 }
288
289 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000290 const canSend =
291 this.inputValue.trim().length > 0 &&
292 !this.disabled &&
293 this.uploadsInProgress === 0;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700294
295 return html`
296 <div class="input-container">
297 <div class="input-wrapper">
298 <textarea
299 ${ref(this.textareaRef)}
300 .value=${this.inputValue}
301 @input=${this.handleInput}
302 @keydown=${this.handleKeyDown}
303 placeholder="Message Sketch..."
Philip Zeyliger73757082025-06-07 00:06:27 +0000304 ?disabled=${this.disabled || this.uploadsInProgress > 0}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700305 rows="1"
306 ></textarea>
Autoformatterf825e692025-06-07 04:19:43 +0000307
Philip Zeyliger73757082025-06-07 00:06:27 +0000308 ${this.showUploadProgress
309 ? html`
310 <div class="upload-progress">
Autoformatterf825e692025-06-07 04:19:43 +0000311 Uploading ${this.uploadsInProgress}
312 file${this.uploadsInProgress > 1 ? "s" : ""}...
Philip Zeyliger73757082025-06-07 00:06:27 +0000313 </div>
314 `
Autoformatterf825e692025-06-07 04:19:43 +0000315 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700316 </div>
317
318 <button
319 class="send-button"
320 @click=${this.sendMessage}
321 ?disabled=${!canSend}
Autoformatterf825e692025-06-07 04:19:43 +0000322 title=${this.uploadsInProgress > 0
323 ? "Please wait for upload to complete"
324 : "Send message"}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700325 >
Philip Zeyliger73757082025-06-07 00:06:27 +0000326 ${this.uploadsInProgress > 0
327 ? html`<span style="font-size: 12px;">⏳</span>`
328 : html`<svg class="send-icon" viewBox="0 0 24 24">
Autoformatterf825e692025-06-07 04:19:43 +0000329 <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
330 </svg>`}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700331 </button>
332 </div>
333 `;
334 }
335}