main
js 511 lines 14.2 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 import { store as imageViewerStore } from "../../modals/image-viewer/image-viewer-store.js";
4
5 const model = {
6 // State properties
7 attachments: [],
8 hasAttachments: false,
9 dragDropOverlayVisible: false,
10
11 async init() {
12 await this.initialize();
13 },
14
15 // Initialize the store
16 async initialize() {
17 // Setup event listeners for drag and drop
18 this.setupDragDropHandlers();
19 // Setup paste event listener for clipboard images
20 this.setupPasteHandler();
21 },
22
23 // Basic attachment management methods
24 addAttachment(attachment) {
25 // Validate for duplicates
26 if (this.validateDuplicates(attachment)) {
27 this.attachments.push(attachment);
28 this.updateAttachmentState();
29 }
30 },
31
32 removeAttachment(index) {
33 if (index >= 0 && index < this.attachments.length) {
34 this.attachments.splice(index, 1);
35 this.updateAttachmentState();
36 }
37 },
38
39 clearAttachments() {
40 this.attachments = [];
41 this.updateAttachmentState();
42 },
43
44 validateDuplicates(newAttachment) {
45 // Check if attachment already exists based on name and size
46 const isDuplicate = this.attachments.some(
47 (existing) =>
48 existing.name === newAttachment.name &&
49 existing.file &&
50 newAttachment.file &&
51 existing.file.size === newAttachment.file.size
52 );
53 return !isDuplicate;
54 },
55
56 updateAttachmentState() {
57 this.hasAttachments = this.attachments.length > 0;
58 },
59
60 // Drag drop overlay control methods
61 showDragDropOverlay() {
62 this.dragDropOverlayVisible = true;
63 },
64
65 hideDragDropOverlay() {
66 this.dragDropOverlayVisible = false;
67 },
68
69 isExternalFileDrag(event) {
70 return Array.from(event?.dataTransfer?.types || []).includes("Files");
71 },
72
73 // Setup drag and drop event handlers
74 setupDragDropHandlers() {
75 console.log("Setting up drag and drop handlers...");
76 let dragCounter = 0;
77
78 // Prevent default drag behaviors
79 ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
80 document.addEventListener(
81 eventName,
82 (e) => {
83 if (!this.isExternalFileDrag(e)) return;
84 e.preventDefault();
85 e.stopPropagation();
86 },
87 false
88 );
89 });
90
91 // Handle drag enter
92 document.addEventListener(
93 "dragenter",
94 (e) => {
95 if (!this.isExternalFileDrag(e)) return;
96 console.log("Drag enter detected");
97 dragCounter++;
98 if (dragCounter === 1) {
99 console.log("Showing drag drop overlay");
100 this.showDragDropOverlay();
101 }
102 },
103 false
104 );
105
106 // Handle drag leave
107 document.addEventListener(
108 "dragleave",
109 (e) => {
110 if (!this.isExternalFileDrag(e) && dragCounter === 0) return;
111 dragCounter--;
112 if (dragCounter === 0) {
113 this.hideDragDropOverlay();
114 }
115 },
116 false
117 );
118
119 // Handle drop
120 document.addEventListener(
121 "drop",
122 async (e) => {
123 if (!this.isExternalFileDrag(e)) return;
124 const dataTransfer = e.dataTransfer;
125 console.log("Drop detected with files:", dataTransfer?.files?.length || 0);
126 dragCounter = 0;
127 this.hideDragDropOverlay();
128
129 let files = [];
130 try {
131 files = await this.getDroppedFiles(dataTransfer);
132 } catch (error) {
133 console.error("Failed to read dropped files:", error);
134 files = Array.from(dataTransfer?.files || []);
135 }
136 this.handleFiles(files);
137 },
138 false
139 );
140 },
141
142 // Setup paste event handler for clipboard images
143 setupPasteHandler() {
144 console.log("Setting up paste handler...");
145 document.addEventListener("paste", (e) => {
146 // console.log("Paste event detected, target:", e.target.id);
147 if (
148 e.target.id !== "chat-input" &&
149 e.target.id !== "full-screen-input"
150 ) return;
151
152 const items = e.clipboardData.items;
153 let imageFound = false;
154 console.log("Checking clipboard items:", items.length);
155
156 // First, check if there are any images in the clipboard
157 for (let i = 0; i < items.length; i++) {
158 const item = items[i];
159 if (item.type.indexOf("image") !== -1) {
160 imageFound = true;
161 const blob = item.getAsFile();
162 if (blob) {
163 e.preventDefault(); // Prevent default paste behavior for images
164 this.handleClipboardImage(blob);
165 console.log("Image detected in clipboard, processing...");
166 }
167 break; // Only handle the first image found
168 }
169 }
170
171 // If no images found and we're in an input field, let normal text paste happen
172 if (
173 !imageFound &&
174 (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
175 ) {
176 console.log(
177 "No images in clipboard, allowing normal text paste in input field"
178 );
179 return;
180 }
181
182 // If no images found and not in input field, do nothing
183 if (!imageFound) {
184 console.log("No images in clipboard");
185 }
186 });
187 },
188
189 // Handle clipboard image pasting
190 async handleClipboardImage(blob) {
191 try {
192 // Generate unique filename
193 const guid = this.generateGUID();
194 const filename = `clipboard-${guid}.png`;
195
196 // Create file object from blob
197 const file = new File([blob], filename, { type: "image/png" });
198
199 // Create attachment object
200 const attachment = {
201 file: file,
202 type: "image",
203 name: filename,
204 extension: "png",
205 displayInfo: this.getAttachmentDisplayInfo(file),
206 };
207
208 // Read as data URL for preview
209 const reader = new FileReader();
210 reader.onload = (e) => {
211 attachment.url = e.target.result;
212 this.addAttachment(attachment);
213 };
214 reader.readAsDataURL(file);
215
216 // Show success feedback
217 console.log("Clipboard image pasted successfully:", filename);
218 } catch (error) {
219 console.error("Failed to handle clipboard image:", error);
220 }
221 },
222
223 // Update handleFileUpload to use the attachments store
224 handleFileUpload(event) {
225 const files = event.target.files;
226 this.handleFiles(files);
227 event.target.value = ""; // clear uploader selection to fix issue where same file is ignored the second time
228 },
229
230 async getDroppedFiles(dataTransfer) {
231 const items = Array.from(dataTransfer?.items || []);
232 const entries = items
233 .map((item) =>
234 typeof item.webkitGetAsEntry === "function"
235 ? item.webkitGetAsEntry()
236 : null
237 )
238 .filter(Boolean);
239
240 if (!entries.length) {
241 return Array.from(dataTransfer?.files || []);
242 }
243
244 const nested = await Promise.all(
245 entries.map((entry) => this.readEntryFiles(entry))
246 );
247 const files = nested.flat();
248 return files.length ? files : Array.from(dataTransfer?.files || []);
249 },
250
251 async readEntryFiles(entry) {
252 if (entry.isFile) {
253 return await new Promise((resolve, reject) => {
254 entry.file(
255 (file) => resolve([file]),
256 (error) => reject(error)
257 );
258 });
259 }
260
261 if (!entry.isDirectory) return [];
262
263 const reader = entry.createReader();
264 const childEntries = await this.readAllDirectoryEntries(reader);
265 const nested = await Promise.all(
266 childEntries.map((child) => this.readEntryFiles(child))
267 );
268 return nested.flat();
269 },
270
271 async readAllDirectoryEntries(reader) {
272 const entries = [];
273
274 return await new Promise((resolve, reject) => {
275 const readBatch = () => {
276 reader.readEntries(
277 (batch) => {
278 if (!batch.length) {
279 resolve(entries);
280 return;
281 }
282 entries.push(...batch);
283 readBatch();
284 },
285 (error) => reject(error)
286 );
287 };
288
289 readBatch();
290 });
291 },
292
293 // File handling logic (moved from index.js)
294 handleFiles(files) {
295 const fileList = Array.from(files || []);
296 console.log("handleFiles called with", fileList.length, "files");
297 fileList.forEach((file) => {
298 if (!file?.name) return;
299
300 console.log("Processing file:", file.name, file.type);
301 const ext = file.name.split(".").pop().toLowerCase();
302 const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes(
303 ext
304 );
305
306 const attachment = {
307 file: file,
308 type: isImage ? "image" : "file",
309 name: file.name,
310 extension: ext,
311 displayInfo: this.getAttachmentDisplayInfo(file),
312 };
313
314 if (isImage) {
315 // Read image as data URL for preview
316 const reader = new FileReader();
317 reader.onload = (e) => {
318 attachment.url = e.target.result;
319 this.addAttachment(attachment);
320 };
321 reader.readAsDataURL(file);
322 } else {
323 // For non-image files, add directly
324 this.addAttachment(attachment);
325 }
326 });
327 },
328
329 // Get attachments for sending message
330 getAttachmentsForSending() {
331 return this.attachments.map((attachment) => {
332 if (attachment.type === "image") {
333 return {
334 ...attachment,
335 url: URL.createObjectURL(attachment.file),
336 };
337 } else {
338 return {
339 ...attachment,
340 };
341 }
342 });
343 },
344
345 // Generate server-side API URL for file (for device sync)
346 getServerImgUrl(filename) {
347 return `/api/image_get?path=/a0/usr/uploads/${encodeURIComponent(filename)}`;
348 },
349
350 getServerFileUrl(filename) {
351 return `/a0/usr/uploads/${encodeURIComponent(filename)}`;
352 },
353
354 // Check if file is an image based on extension
355 isImageFile(filename) {
356 const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
357 const extension = filename.split(".").pop().toLowerCase();
358 return imageExtensions.includes(extension);
359 },
360
361 // Get attachment preview URL (server URL for persistence, blob URL for current session)
362 getAttachmentPreviewUrl(attachment) {
363 // If attachment has a name and we're dealing with a server-stored file
364 if (typeof attachment === "string") {
365 // attachment is just a filename (from loaded chat)
366 return this.getServerImgUrl(attachment);
367 } else if (attachment.name && attachment.file) {
368 // attachment is an object from current session
369 if (attachment.type === "image") {
370 // For images, use blob URL for current session preview
371 return attachment.url || URL.createObjectURL(attachment.file);
372 } else {
373 // For non-image files, use server URL to get appropriate icon
374 return this.getServerImgUrl(attachment.name);
375 }
376 }
377 return null;
378 },
379
380 getFilePreviewUrl(filename) {
381 const extension = filename.split(".").pop().toLowerCase();
382 const types = {
383 // Archive files
384 zip: "archive",
385 rar: "archive",
386 "7z": "archive",
387 tar: "archive",
388 gz: "archive",
389 // Document files
390 pdf: "document",
391 doc: "document",
392 docx: "document",
393 txt: "document",
394 rtf: "document",
395 odt: "document",
396 // Code files
397 py: "code",
398 js: "code",
399 html: "code",
400 css: "code",
401 json: "code",
402 xml: "code",
403 md: "code",
404 yml: "code",
405 yaml: "code",
406 sql: "code",
407 sh: "code",
408 bat: "code",
409 // Spreadsheet files
410 xls: "document",
411 xlsx: "document",
412 csv: "document",
413 // Presentation files
414 ppt: "document",
415 pptx: "document",
416 odp: "document",
417 };
418 const type = types[extension] || "file";
419 return `/public/${type}.svg`;
420 },
421
422 // Enhanced method to get attachment display info for UI
423 getAttachmentDisplayInfo(attachment) {
424 if (typeof attachment === "string") {
425 // attachment is filename only (from persistent storage)
426 const filename = attachment;
427 const extension = filename.split(".").pop();
428 const isImage = this.isImageFile(filename);
429 const previewUrl = isImage
430 ? this.getServerImgUrl(filename)
431 : this.getFilePreviewUrl(filename);
432
433 return {
434 filename: filename,
435 extension: extension.toUpperCase(),
436 isImage: isImage,
437 previewUrl: previewUrl,
438 clickHandler: () => {
439 if (this.isImageFile(filename)) {
440 imageViewerStore.open(this.getServerImgUrl(filename), { name: filename });
441 } else {
442 this.downloadAttachment(filename);
443 }
444 },
445 };
446 } else {
447 // attachment is object (from current session)
448 const isImage = this.isImageFile(attachment.name);
449 const filename = attachment.name;
450 const extension = filename.split(".").pop() || "";
451 const previewUrl = isImage
452 ? this.getServerImgUrl(attachment.name)
453 : this.getFilePreviewUrl(attachment.name);
454 return {
455 filename: filename,
456 extension: extension.toUpperCase(),
457 isImage: attachment.type === "image",
458 previewUrl: previewUrl,
459 clickHandler: () => {
460 if (attachment.type === "image") {
461 const imageUrl = this.getServerImgUrl(attachment.name);
462 imageViewerStore.open(imageUrl, { name: attachment.name });
463 } else {
464 this.downloadAttachment(attachment.name);
465 }
466 },
467 };
468 }
469 },
470
471 async downloadAttachment(filename) {
472 try {
473 const path = this.getServerFileUrl(filename);
474 const response = await fetchApi("/download_work_dir_file?path=" + path);
475
476 if (!response.ok) {
477 throw new Error("Network response was not ok");
478 }
479
480 const blob = await response.blob();
481
482 const link = document.createElement("a");
483 link.href = window.URL.createObjectURL(blob);
484 link.download = filename;
485 document.body.appendChild(link);
486 link.click();
487 document.body.removeChild(link);
488 window.URL.revokeObjectURL(link.href);
489 } catch (error) {
490 window.toastFetchError("Error downloading file", error);
491 alert("Error downloading file");
492 }
493 },
494
495 // Generate GUID for unique filenames
496 generateGUID() {
497 return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
498 /[xy]/g,
499 function (c) {
500 const r = (Math.random() * 16) | 0;
501 const v = c == "x" ? r : (r & 0x3) | 0x8;
502 return v.toString(16);
503 }
504 );
505 },
506
507 };
508
509 const store = createStore("chatAttachments", model);
510
511 export { store };