blob: ff208d5500fd3fb7c9486c6fb7bb914d9c2980c6 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
banksean23a35b82025-07-20 21:18:31 +00002import { html } from "lit";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07003import { customElement, property, state } from "lit/decorators.js";
4import { createRef, ref } from "lit/directives/ref.js";
banksean23a35b82025-07-20 21:18:31 +00005import { SketchTailwindElement } from "./sketch-tailwind-element";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07006
7@customElement("mobile-chat-input")
banksean23a35b82025-07-20 21:18:31 +00008export class MobileChatInput extends SketchTailwindElement {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07009 @property({ type: Boolean })
10 disabled = false;
11
12 @state()
13 private inputValue = "";
14
Philip Zeyliger73757082025-06-07 00:06:27 +000015 @state()
16 private uploadsInProgress = 0;
17
18 @state()
19 private showUploadProgress = false;
20
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070021 private textareaRef = createRef<HTMLTextAreaElement>();
22
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070023 private handleInput = (e: Event) => {
24 const target = e.target as HTMLTextAreaElement;
25 this.inputValue = target.value;
26 this.adjustTextareaHeight();
27 };
28
Philip Zeyliger73757082025-06-07 00:06:27 +000029 private handlePaste = async (e: ClipboardEvent) => {
30 // Check if the clipboard contains files
31 if (e.clipboardData && e.clipboardData.files.length > 0) {
32 const file = e.clipboardData.files[0];
Autoformatterf825e692025-06-07 04:19:43 +000033
Philip Zeyliger73757082025-06-07 00:06:27 +000034 // Handle the file upload
35 e.preventDefault(); // Prevent default paste behavior
Autoformatterf825e692025-06-07 04:19:43 +000036
Philip Zeyliger73757082025-06-07 00:06:27 +000037 // Get the current cursor position
38 const textarea = this.textareaRef.value;
Autoformatterf825e692025-06-07 04:19:43 +000039 const cursorPos = textarea
40 ? textarea.selectionStart
41 : this.inputValue.length;
Philip Zeyliger73757082025-06-07 00:06:27 +000042 await this.uploadFile(file, cursorPos);
43 }
44 };
45
46 // Utility function to handle file uploads
47 private async uploadFile(file: File, insertPosition: number) {
48 // Insert a placeholder at the cursor position
49 const textBefore = this.inputValue.substring(0, insertPosition);
50 const textAfter = this.inputValue.substring(insertPosition);
51
52 // Add a loading indicator
53 const loadingText = `[🔄 Uploading ${file.name}...]`;
54 this.inputValue = `${textBefore}${loadingText}${textAfter}`;
55
56 // Increment uploads in progress counter
57 this.uploadsInProgress++;
58 this.showUploadProgress = true;
59
60 // Adjust spacing immediately to show loading indicator
61 this.adjustTextareaHeight();
62
63 try {
64 // Create a FormData object to send the file
65 const formData = new FormData();
66 formData.append("file", file);
67
68 // Upload the file to the server using a relative path
69 const response = await fetch("./upload", {
70 method: "POST",
71 body: formData,
72 });
73
74 if (!response.ok) {
75 throw new Error(`Upload failed: ${response.statusText}`);
76 }
77
78 const data = await response.json();
79
80 // Replace the loading placeholder with the actual file path
81 this.inputValue = this.inputValue.replace(loadingText, `[${data.path}]`);
82
83 return data.path;
84 } catch (error) {
85 console.error("Failed to upload file:", error);
86
87 // Replace loading indicator with error message
88 const errorText = `[Upload failed: ${error.message}]`;
89 this.inputValue = this.inputValue.replace(loadingText, errorText);
90
91 throw error;
92 } finally {
93 // Always decrement the counter, even if there was an error
94 this.uploadsInProgress--;
95 if (this.uploadsInProgress === 0) {
96 this.showUploadProgress = false;
97 }
98 this.adjustTextareaHeight();
99 if (this.textareaRef.value) {
100 this.textareaRef.value.focus();
101 }
102 }
103 }
104
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700105 private handleKeyDown = (e: KeyboardEvent) => {
106 if (e.key === "Enter" && !e.shiftKey) {
107 e.preventDefault();
108 this.sendMessage();
109 }
110 };
111
112 private adjustTextareaHeight() {
113 if (this.textareaRef.value) {
114 const textarea = this.textareaRef.value;
115 textarea.style.height = "auto";
116 textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
117 }
118 }
119
120 private sendMessage = () => {
Philip Zeyliger73757082025-06-07 00:06:27 +0000121 // Prevent sending if there are uploads in progress
122 if (this.uploadsInProgress > 0) {
Autoformatterf825e692025-06-07 04:19:43 +0000123 console.log(
124 `Message send prevented: ${this.uploadsInProgress} uploads in progress`,
125 );
Philip Zeyliger73757082025-06-07 00:06:27 +0000126 return;
127 }
128
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700129 const message = this.inputValue.trim();
130 if (message && !this.disabled) {
131 this.dispatchEvent(
132 new CustomEvent("send-message", {
133 detail: { message },
134 bubbles: true,
135 composed: true,
136 }),
137 );
138
139 this.inputValue = "";
140 if (this.textareaRef.value) {
141 this.textareaRef.value.value = "";
142 this.adjustTextareaHeight();
143 this.textareaRef.value.focus();
144 }
145 }
146 };
147
148 updated(changedProperties: Map<string, any>) {
149 super.updated(changedProperties);
150 this.adjustTextareaHeight();
Autoformatterf825e692025-06-07 04:19:43 +0000151
Philip Zeyliger73757082025-06-07 00:06:27 +0000152 // Add paste event listener when component updates
153 if (this.textareaRef.value && !this.textareaRef.value.onpaste) {
Autoformatterf825e692025-06-07 04:19:43 +0000154 this.textareaRef.value.addEventListener("paste", this.handlePaste);
Philip Zeyliger73757082025-06-07 00:06:27 +0000155 }
156 }
157
158 disconnectedCallback() {
159 super.disconnectedCallback();
160 // Clean up paste event listener
161 if (this.textareaRef.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000162 this.textareaRef.value.removeEventListener("paste", this.handlePaste);
Philip Zeyliger73757082025-06-07 00:06:27 +0000163 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700164 }
165
166 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000167 const canSend =
168 this.inputValue.trim().length > 0 &&
169 !this.disabled &&
170 this.uploadsInProgress === 0;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700171
172 return html`
banksean23a35b82025-07-20 21:18:31 +0000173 <div
174 class="block bg-white border-t border-gray-200 p-3 relative z-[1000]"
175 style="padding-bottom: max(12px, env(safe-area-inset-bottom)); padding-left: max(16px, env(safe-area-inset-left)); padding-right: max(16px, env(safe-area-inset-right));"
176 >
177 <div class="flex items-end gap-3 max-w-full">
178 <div class="flex-1 relative min-w-0">
179 <textarea
180 ${ref(this.textareaRef)}
181 .value=${this.inputValue}
182 @input=${this.handleInput}
183 @keydown=${this.handleKeyDown}
184 placeholder="Message Sketch..."
185 ?disabled=${this.disabled || this.uploadsInProgress > 0}
186 rows="1"
187 class="w-full min-h-[40px] max-h-[120px] p-3 border border-gray-300 rounded-[20px] text-base font-inherit leading-relaxed resize-none outline-none bg-gray-50 transition-colors duration-200 box-border focus:border-blue-500 focus:bg-white disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed placeholder:text-gray-500"
188 style="font-size: 16px;"
189 ></textarea>
Autoformatterf825e692025-06-07 04:19:43 +0000190
banksean23a35b82025-07-20 21:18:31 +0000191 ${this.showUploadProgress
192 ? html`
193 <div
194 class="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-yellow-50 text-yellow-800 px-2 py-1 rounded text-xs whitespace-nowrap shadow-sm z-[1000]"
195 >
196 Uploading ${this.uploadsInProgress}
197 file${this.uploadsInProgress > 1 ? "s" : ""}...
198 </div>
199 `
200 : ""}
201 </div>
202
203 <button
204 class="flex-shrink-0 w-10 h-10 border-none rounded-full bg-blue-500 text-white cursor-pointer flex items-center justify-center text-lg transition-all duration-200 outline-none hover:bg-blue-600 active:scale-95 disabled:bg-gray-500 disabled:cursor-not-allowed disabled:opacity-60"
205 @click=${this.sendMessage}
206 ?disabled=${!canSend}
207 title=${this.uploadsInProgress > 0
208 ? "Please wait for upload to complete"
209 : "Send message"}
210 >
211 ${this.uploadsInProgress > 0
212 ? html`<span class="text-xs">⏳</span>`
213 : html`<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24">
214 <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
215 </svg>`}
216 </button>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700217 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700218 </div>
219 `;
220 }
221}