improvements to drag drop, image modal

deci committed Jun 23, 2025 at 16:16 UTC c6ba915b16e71071da3b9e9f5135c3deebef5bcd
8 files changed +406 -88
webui/components/chat/attachments/attachmentsStore.js
+114 -23
@@ -1,17 +1,28 @@
1 +console.log('attachmentsStore.js module loading...');
2 import { createStore } from "/js/AlpineStore.js";
3
4 +console.log('attachmentsStore.js module loaded, creating model...');
5 const model = {
6 // State properties
7 attachments: [],
8 hasAttachments: false,
9 dragDropOverlayVisible: false,
10
11 + // Image modal properties
12 + currentImageUrl: null,
13 + currentImageName: null,
14 + imageLoaded: false,
15 + imageError: false,
16 + zoomLevel: 1,
17 +
18 // Initialize the store
19 async initialize() {
20 + console.log('Initializing attachments store...');
21 // Setup event listeners for drag and drop
22 this.setupDragDropHandlers();
23 // Setup paste event listener for clipboard images
24 this.setupPasteHandler();
25 + console.log('Attachments store initialized successfully');
26 },
27
28 // Basic attachment management methods
@@ -60,6 +71,7 @@ const model = {
71
72 // Setup drag and drop event handlers
73 setupDragDropHandlers() {
74 + console.log('Setting up drag and drop handlers...');
75 let dragCounter = 0;
76
77 // Prevent default drag behaviors
@@ -72,8 +84,10 @@ const model = {
84
85 // Handle drag enter
86 document.addEventListener('dragenter', (e) => {
87 + console.log('Drag enter detected');
88 dragCounter++;
89 if (dragCounter === 1) {
90 + console.log('Showing drag drop overlay');
91 this.showDragDropOverlay();
92 }
93 }, false);
@@ -88,6 +102,7 @@ const model = {
102
103 // Handle drop
104 document.addEventListener('drop', (e) => {
105 + console.log('Drop detected with files:', e.dataTransfer.files.length);
106 dragCounter = 0;
107 this.hideDragDropOverlay();
108
@@ -98,47 +113,76 @@ const model = {
113
114 // Setup paste event handler for clipboard images
115 setupPasteHandler() {
116 + console.log('Setting up paste handler...');
117 document.addEventListener('paste', (e) => {
118 + console.log('Paste event detected, target:', e.target.tagName);
119 + // Only handle paste when not in an input field (to avoid interfering with text pasting)
120 + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
121 + console.log('Ignoring paste in input field');
122 + return;
123 + }
124 +
125 const items = e.clipboardData.items;
126 + let imageFound = false;
127 + console.log('Checking clipboard items:', items.length);
128 +
129 for (let i = 0; i < items.length; i++) {
130 const item = items[i];
131 if (item.type.indexOf('image') !== -1) {
132 + imageFound = true;
133 const blob = item.getAsFile();
107 - this.handleClipboardImage(blob);
134 + if (blob) {
135 + e.preventDefault(); // Prevent default paste behavior for images
136 + this.handleClipboardImage(blob);
137 + }
138 }
139 }
140 +
141 + if (imageFound) {
142 + console.log('Image detected in clipboard, processing...');
143 + }
144 });
145 },
146
147 // Handle clipboard image pasting
148 async handleClipboardImage(blob) {
115 - // Generate unique filename
116 - const guid = this.generateGUID();
117 - const filename = `clipboard-${guid}.png`;
118 -
119 - // Create file object from blob
120 - const file = new File([blob], filename, { type: 'image/png' });
121 -
122 - // Create attachment object
123 - const attachment = {
124 - file: file,
125 - type: 'image',
126 - name: filename,
127 - extension: 'png'
128 - };
129 -
130 - // Read as data URL for preview
131 - const reader = new FileReader();
132 - reader.onload = (e) => {
133 - attachment.url = e.target.result;
134 - this.addAttachment(attachment);
135 - };
136 - reader.readAsDataURL(file);
149 + try {
150 + // Generate unique filename
151 + const guid = this.generateGUID();
152 + const filename = `clipboard-${guid}.png`;
153 +
154 + // Create file object from blob
155 + const file = new File([blob], filename, { type: 'image/png' });
156 +
157 + // Create attachment object
158 + const attachment = {
159 + file: file,
160 + type: 'image',
161 + name: filename,
162 + extension: 'png'
163 + };
164 +
165 + // Read as data URL for preview
166 + const reader = new FileReader();
167 + reader.onload = (e) => {
168 + attachment.url = e.target.result;
169 + this.addAttachment(attachment);
170 + };
171 + reader.readAsDataURL(file);
172 +
173 + // Show success feedback
174 + console.log('Clipboard image pasted successfully:', filename);
175 +
176 + } catch (error) {
177 + console.error('Failed to handle clipboard image:', error);
178 + }
179 },
180
181 // File handling logic (moved from index.js)
182 handleFiles(files) {
183 + console.log('handleFiles called with', files.length, 'files');
184 Array.from(files).forEach(file => {
185 + console.log('Processing file:', file.name, file.type);
186 const ext = file.name.split('.').pop().toLowerCase();
187 const isImage = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp'].includes(ext);
188
@@ -187,9 +231,56 @@ const model = {
231 const v = c == 'x' ? r : (r & 0x3 | 0x8);
232 return v.toString(16);
233 });
234 + },
235 +
236 + // Image modal methods
237 + openImageModal(imageUrl, imageName) {
238 + this.currentImageUrl = imageUrl;
239 + this.currentImageName = imageName;
240 + this.imageLoaded = false;
241 + this.imageError = false;
242 + this.zoomLevel = 1;
243 +
244 + // Open the modal using the modals system
245 + if (window.openModal) {
246 + window.openModal('chat/attachments/imageModal.html');
247 + }
248 + },
249 +
250 + closeImageModal() {
251 + this.currentImageUrl = null;
252 + this.currentImageName = null;
253 + this.imageLoaded = false;
254 + this.imageError = false;
255 + this.zoomLevel = 1;
256 + },
257 +
258 + // Zoom controls
259 + zoomIn() {
260 + this.zoomLevel = Math.min(this.zoomLevel * 1.2, 5); // Max 5x zoom
261 + this.updateImageZoom();
262 + },
263 +
264 + zoomOut() {
265 + this.zoomLevel = Math.max(this.zoomLevel / 1.2, 0.1); // Min 0.1x zoom
266 + this.updateImageZoom();
267 + },
268 +
269 + resetZoom() {
270 + this.zoomLevel = 1;
271 + this.updateImageZoom();
272 + },
273 +
274 + updateImageZoom() {
275 + const img = document.querySelector('.modal-image');
276 + if (img) {
277 + img.style.transform = `scale(${this.zoomLevel})`;
278 + }
279 }
280 };
281
282 +console.log('Creating chatAttachments store...');
283 const store = createStore("chatAttachments", model);
284 +console.log('chatAttachments store created:', store);
285
286 export { store };
\ No newline at end of file
webui/components/chat/attachments/imageModal.html new
+148
@@ -0,0 +1,148 @@
1 +<html>
2 +
3 +<head>
4 + <title>Image Viewer</title>
5 +
6 + <script type="module">
7 + import { store } from "/components/chat/attachments/attachmentsStore.js";
8 + </script>
9 +</head>
10 +
11 +<body>
12 + <div x-data>
13 + <template x-if="$store.chatAttachments">
14 + <div id="image-modal-content" class="image-modal-container">
15 + <!-- Image display area -->
16 + <div class="image-display-wrapper">
17 + <img
18 + x-show="$store.chatAttachments.currentImageUrl"
19 + :src="$store.chatAttachments.currentImageUrl"
20 + :alt="$store.chatAttachments.currentImageName || 'Image'"
21 + class="modal-image"
22 + @load="$store.chatAttachments.imageLoaded = true"
23 + @error="$store.chatAttachments.imageError = true"
24 + />
25 +
26 + <!-- Loading indicator -->
27 + <div x-show="!$store.chatAttachments.imageLoaded && !$store.chatAttachments.imageError" class="loading-indicator">
28 + <div class="loading-spinner"></div>
29 + <p>Loading image...</p>
30 + </div>
31 +
32 + <!-- Error indicator -->
33 + <div x-show="$store.chatAttachments.imageError" class="error-indicator">
34 + <p>Failed to load image</p>
35 + </div>
36 + </div>
37 +
38 + <!-- Simple zoom controls -->
39 + <div class="zoom-controls">
40 + <button @click="$store.chatAttachments.zoomOut()" class="zoom-btn" title="Zoom Out">−</button>
41 + <button @click="$store.chatAttachments.resetZoom()" class="zoom-btn" title="Reset">⌂</button>
42 + <button @click="$store.chatAttachments.zoomIn()" class="zoom-btn" title="Zoom In">+</button>
43 + </div>
44 + </div>
45 + </template>
46 + </div>
47 +
48 + <style>
49 + .image-modal-container {
50 + width: 100%;
51 + height: 100%;
52 + display: flex;
53 + flex-direction: column;
54 + align-items: center;
55 + justify-content: center;
56 + position: relative;
57 + background: var(--color-bg-secondary);
58 + }
59 +
60 + .image-display-wrapper {
61 + flex: 1;
62 + display: flex;
63 + align-items: center;
64 + justify-content: center;
65 + overflow: auto;
66 + width: 100%;
67 + height: 100%;
68 + position: relative;
69 + }
70 +
71 + .modal-image {
72 + max-width: 100%;
73 + max-height: 100%;
74 + object-fit: contain;
75 + transition: transform 0.2s ease;
76 + cursor: grab;
77 + }
78 +
79 + .modal-image:active {
80 + cursor: grabbing;
81 + }
82 +
83 + .loading-indicator, .error-indicator {
84 + display: flex;
85 + flex-direction: column;
86 + align-items: center;
87 + justify-content: center;
88 + color: var(--color-text-secondary);
89 + }
90 +
91 + .loading-spinner {
92 + width: 40px;
93 + height: 40px;
94 + border: 3px solid var(--color-border);
95 + border-top: 3px solid var(--color-primary);
96 + border-radius: 50%;
97 + animation: spin 1s linear infinite;
98 + margin-bottom: 10px;
99 + }
100 +
101 + @keyframes spin {
102 + 0% { transform: rotate(0deg); }
103 + 100% { transform: rotate(360deg); }
104 + }
105 +
106 + .zoom-controls {
107 + position: absolute;
108 + bottom: 20px;
109 + right: 20px;
110 + display: flex;
111 + align-items: center;
112 + gap: 4px;
113 + background: rgba(0, 0, 0, 0.6);
114 + padding: 6px;
115 + border-radius: 12px;
116 + backdrop-filter: blur(5px);
117 + }
118 +
119 + .zoom-btn {
120 + background: transparent;
121 + border: none;
122 + color: white;
123 + cursor: pointer;
124 + padding: 6px 10px;
125 + border-radius: 6px;
126 + font-size: 16px;
127 + font-weight: 500;
128 + min-width: 32px;
129 + height: 32px;
130 + display: flex;
131 + align-items: center;
132 + justify-content: center;
133 + transition: background-color 0.2s ease;
134 + }
135 +
136 + .zoom-btn:hover {
137 + background: rgba(255, 255, 255, 0.15);
138 + }
139 +
140 + /* Dark mode adjustments */
141 + .dark-mode .image-modal-container {
142 + background: var(--color-bg-secondary);
143 + }
144 + </style>
145 +
146 +</body>
147 +
148 +</html>
\ No newline at end of file
webui/index.html
+15 -13
@@ -379,7 +379,9 @@
379 <template x-for="(attachment, index) in $store.chatAttachments.attachments" :key="index">
380 <div class="preview-item" :class="{'image-preview': attachment.type === 'image'}">
381 <template x-if="attachment.type === 'image'">
382 - <img :src="attachment.url" :alt="attachment.name">
382 + <img :src="attachment.url" :alt="attachment.name"
383 + style="cursor: pointer;"
384 + @click="$store.chatAttachments.openImageModal(attachment.url, attachment.name)">
385 </template>
386 <template x-if="attachment.type === 'file'">
387 <div class="file-preview">
@@ -681,20 +683,20 @@
683 <div id="section-tunnel" class="section" x-show="activeTab === 'external'">
684 <div class="section-title">Flare Tunnel</div>
685 <div class="section-description">Create a secure public URL to access your Agent Zero instance anytime, anywhere.</div>
684 - <div class="field">
685 - <div class="field-label">
686 - <div class="field-title">Tunnel provider</div>
687 - <div class="field-description">Select provider for public tunnel</div>
688 - </div>
689 - <div class="field-control">
690 - <select id="tunnel-provider" x-model="provider" :disabled="isLoading">
691 - <option value="serveo">Serveo</option>
692 - <option value="cloudflared">Cloudflare</option>
693 - </select>
694 - </div>
695 - </div>
686 <!-- Tunnel content UI -->
687 <div class="tunnel-container" x-data="tunnelSettings">
688 + <div class="field">
689 + <div class="field-label">
690 + <div class="field-title">Tunnel provider</div>
691 + <div class="field-description">Select provider for public tunnel</div>
692 + </div>
693 + <div class="field-control">
694 + <select id="tunnel-provider" x-model="provider" :disabled="isLoading">
695 + <option value="serveo">Serveo</option>
696 + <option value="cloudflared">Cloudflare</option>
697 + </select>
698 + </div>
699 + </div>
700 <!-- Loading spinner for tunnel operations -->
701 <div class="loading-spinner" x-show="isLoading">
702 <i class="fas fa-spinner fa-spin"></i>
webui/index.js
+72 -41
@@ -84,7 +84,7 @@ document.addEventListener('DOMContentLoaded', setupSidebarToggle);
84 export async function sendMessage() {
85 try {
86 const message = chatInput.value.trim();
87 - const attachmentsStore = Alpine.store('chatAttachments');
87 + const attachmentsStore = window.Alpine ? Alpine.store('chatAttachments') : null;
88 const attachments = attachmentsStore ? attachmentsStore.attachments : [];
89 const hasAttachments = attachmentsStore ? attachmentsStore.hasAttachments : false;
90
@@ -321,8 +321,15 @@ function getConnectionStatus() {
321
322 function setConnectionStatus(connected) {
323 connectionStatus = connected
324 - const statusIcon = Alpine.$data(timeDate.querySelector('.status-icon'));
325 - statusIcon.connected = connected
324 + if (window.Alpine && timeDate) {
325 + const statusIconEl = timeDate.querySelector('.status-icon');
326 + if (statusIconEl) {
327 + const statusIcon = Alpine.$data(statusIconEl);
328 + if (statusIcon) {
329 + statusIcon.connected = connected;
330 + }
331 + }
332 + }
333 }
334
335 let lastLogVersion = 0;
@@ -373,37 +380,48 @@ async function poll() {
380 updateProgress(response.log_progress, response.log_progress_active)
381
382 //set ui model vars from backend
376 - const inputAD = Alpine.$data(inputSection);
377 - inputAD.paused = response.paused;
383 + if (window.Alpine && inputSection) {
384 + const inputAD = Alpine.$data(inputSection);
385 + if (inputAD) {
386 + inputAD.paused = response.paused;
387 + }
388 + }
389
390 // Update status icon state
391 setConnectionStatus(true)
392
393 // Update chats list and sort by created_at time (newer first)
383 - const chatsAD = Alpine.$data(chatsSection);
384 - const contexts = response.contexts || [];
385 - chatsAD.contexts = contexts.sort((a, b) =>
386 - (b.created_at || 0) - (a.created_at || 0)
387 - );
394 + let chatsAD = null;
395 + let contexts = response.contexts || [];
396 + if (window.Alpine && chatsSection) {
397 + chatsAD = Alpine.$data(chatsSection);
398 + if (chatsAD) {
399 + chatsAD.contexts = contexts.sort((a, b) =>
400 + (b.created_at || 0) - (a.created_at || 0)
401 + );
402 + }
403 + }
404
405 // Update tasks list and sort by creation time (newer first)
406 const tasksSection = document.getElementById('tasks-section');
391 - if (tasksSection) {
407 + if (window.Alpine && tasksSection) {
408 const tasksAD = Alpine.$data(tasksSection);
393 - let tasks = response.tasks || [];
394 -
395 - // Always update tasks to ensure state changes are reflected
396 - if (tasks.length > 0) {
397 - // Sort the tasks by creation time
398 - const sortedTasks = [...tasks].sort((a, b) =>
399 - (b.created_at || 0) - (a.created_at || 0)
400 - );
401 -
402 - // Assign the sorted tasks to the Alpine data
403 - tasksAD.tasks = sortedTasks;
404 - } else {
405 - // Make sure to use a new empty array instance
406 - tasksAD.tasks = [];
409 + if (tasksAD) {
410 + let tasks = response.tasks || [];
411 +
412 + // Always update tasks to ensure state changes are reflected
413 + if (tasks.length > 0) {
414 + // Sort the tasks by creation time
415 + const sortedTasks = [...tasks].sort((a, b) =>
416 + (b.created_at || 0) - (a.created_at || 0)
417 + );
418 +
419 + // Assign the sorted tasks to the Alpine data
420 + tasksAD.tasks = sortedTasks;
421 + } else {
422 + // Make sure to use a new empty array instance
423 + tasksAD.tasks = [];
424 + }
425 }
426 }
427
@@ -412,7 +430,7 @@ async function poll() {
430 // Update selection in the active tab
431 const activeTab = localStorage.getItem('activeTab') || 'chats';
432
415 - if (activeTab === 'chats') {
433 + if (activeTab === 'chats' && chatsAD) {
434 chatsAD.selected = context;
435 localStorage.setItem('lastSelectedChat', context);
436
@@ -457,7 +475,7 @@ async function poll() {
475 tasksAD.selected = firstTaskId;
476 localStorage.setItem('lastSelectedTask', firstTaskId);
477 }
460 - } else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats') {
478 + } else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats' && chatsAD) {
479 // If we're in chats tab with no selection but have chats, select the first one
480 const firstChatId = contexts[0].id;
481
@@ -681,11 +699,16 @@ export const setContext = function (id) {
699 chatHistory.innerHTML = "";
700
701 // Update both selected states
684 - const chatsAD = Alpine.$data(chatsSection);
685 - const tasksAD = Alpine.$data(tasksSection);
686 -
687 - chatsAD.selected = id;
688 - tasksAD.selected = id;
702 + if (window.Alpine) {
703 + if (chatsSection) {
704 + const chatsAD = Alpine.$data(chatsSection);
705 + if (chatsAD) chatsAD.selected = id;
706 + }
707 + if (tasksSection) {
708 + const tasksAD = Alpine.$data(tasksSection);
709 + if (tasksAD) tasksAD.selected = id;
710 + }
711 + }
712 }
713
714 export const getContext = function () {
@@ -1021,8 +1044,12 @@ function hideToast() {
1044 }
1045
1046 function scrollChanged(isAtBottom) {
1024 - const inputAS = Alpine.$data(autoScrollSwitch);
1025 - inputAS.autoScroll = isAtBottom
1047 + if (window.Alpine && autoScrollSwitch) {
1048 + const inputAS = Alpine.$data(autoScrollSwitch);
1049 + if (inputAS) {
1050 + inputAS.autoScroll = isAtBottom;
1051 + }
1052 + }
1053 // autoScrollSwitch.checked = isAtBottom
1054 }
1055
@@ -1073,9 +1100,13 @@ document.addEventListener("DOMContentLoaded", startPolling);
1100
1101 // Update handleFileUpload to use the attachments store
1102 window.handleFileUpload = function(event) {
1103 + console.log('handleFileUpload called with files:', event.target.files.length);
1104 const files = event.target.files;
1077 - if (Alpine.store('chatAttachments')) {
1105 + if (window.Alpine && Alpine.store('chatAttachments')) {
1106 + console.log('Calling store handleFiles...');
1107 Alpine.store('chatAttachments').handleFiles(files);
1108 + } else {
1109 + console.error('Alpine or chatAttachments store not found!');
1110 }
1111 }
1112
@@ -1137,8 +1168,8 @@ function activateTab(tabName) {
1168 chatsSection.style.display = '';
1169
1170 // Get the available contexts from Alpine.js data
1140 - const chatsAD = Alpine.$data(chatsSection);
1141 - const availableContexts = chatsAD.contexts || [];
1171 + const chatsAD = window.Alpine ? Alpine.$data(chatsSection) : null;
1172 + const availableContexts = chatsAD?.contexts || [];
1173
1174 // Restore previous chat selection
1175 const lastSelectedChat = localStorage.getItem('lastSelectedChat');
@@ -1158,8 +1189,8 @@ function activateTab(tabName) {
1189 tasksSection.style.flexDirection = 'column';
1190
1191 // Get the available tasks from Alpine.js data
1161 - const tasksAD = Alpine.$data(tasksSection);
1162 - const availableTasks = tasksAD.tasks || [];
1192 + const tasksAD = window.Alpine ? Alpine.$data(tasksSection) : null;
1193 + const availableTasks = tasksAD?.tasks || [];
1194
1195 // Restore previous task selection
1196 const lastSelectedTask = localStorage.getItem('lastSelectedTask');
@@ -1223,7 +1254,7 @@ function openTaskDetail(taskId) {
1254 }
1255
1256 // Get the Alpine.js data for the modal
1226 - const modalData = Alpine.$data(modalEl);
1257 + const modalData = window.Alpine ? Alpine.$data(modalEl) : null;
1258
1259 // Use a timeout to ensure the modal is fully rendered
1260 setTimeout(() => {
@@ -1240,7 +1271,7 @@ function openTaskDetail(taskId) {
1271 }
1272
1273 // Get the Alpine.js data for the scheduler component
1243 - const schedulerData = Alpine.$data(schedulerComponent);
1274 + const schedulerData = window.Alpine ? Alpine.$data(schedulerComponent) : null;
1275
1276 // Show the task detail view for the specific task
1277 schedulerData.showTaskDetail(taskId);
webui/js/AlpineStore.js
+8 -1
@@ -10,6 +10,7 @@ const stores = new Map();
10 * @returns {T}
11 */
12 export function createStore(name, initialState) {
13 + console.log('createStore called for:', name);
14 const proxy = new Proxy(initialState, {
15 set(target, prop, value) {
16 const store = globalThis.Alpine?.store(name);
@@ -23,13 +24,19 @@ export function createStore(name, initialState) {
24 });
25
26 if (globalThis.Alpine) {
27 + console.log('Alpine available, registering store immediately:', name);
28 globalThis.Alpine.store(name, initialState);
29 } else {
28 - document.addEventListener("alpine:init", () => Alpine.store(name, initialState));
30 + console.log('Alpine not available, waiting for alpine:init for store:', name);
31 + document.addEventListener("alpine:init", () => {
32 + console.log('alpine:init fired, registering store:', name);
33 + Alpine.store(name, initialState);
34 + });
35 }
36
37 // Store the proxy
38 stores.set(name, proxy);
39 + console.log('Store proxy created and stored for:', name);
40
41 return /** @type {T} */ (proxy); // explicitly cast for linter support
42 }
webui/js/initFw.js
+30 -4
@@ -4,7 +4,9 @@ import * as _components from "./components.js";
4 await import("./alpine.min.js");
5
6 // Import attachments store
7 +console.log('initFw.js: About to import attachments store...');
8 import { store as attachmentsStore } from "../components/chat/attachments/attachmentsStore.js";
9 +console.log('initFw.js: Attachments store imported:', attachmentsStore);
10
11 // add x-destroy directive
12 Alpine.directive(
@@ -16,10 +18,34 @@ Alpine.directive(
18 );
19
20 // Initialize attachments store when Alpine.js is ready
19 -document.addEventListener('alpine:init', () => {
20 - // Store is already created via createStore in attachmentsStore.js
21 - // Just call initialize to set up event handlers
22 - if (Alpine.store('chatAttachments')) {
21 +function initializeAttachmentsStore() {
22 + console.log('Attempting to initialize attachments store...');
23 + if (window.Alpine && Alpine.store('chatAttachments')) {
24 + console.log('chatAttachments store found, initializing...');
25 Alpine.store('chatAttachments').initialize();
26 + return true;
27 + } else {
28 + console.log('Alpine or store not ready yet, will retry...');
29 + return false;
30 }
31 +}
32 +
33 +// Try multiple initialization approaches
34 +document.addEventListener('alpine:init', () => {
35 + console.log('Alpine.js alpine:init event fired');
36 + setTimeout(() => initializeAttachmentsStore(), 100);
37 });
38 +
39 +// Also try when Alpine is fully initialized
40 +document.addEventListener('alpine:initialized', () => {
41 + console.log('Alpine.js alpine:initialized event fired');
42 + initializeAttachmentsStore();
43 +});
44 +
45 +// Fallback: try after a delay
46 +setTimeout(() => {
47 + if (!initializeAttachmentsStore()) {
48 + console.log('Retrying attachments store initialization after delay...');
49 + setTimeout(initializeAttachmentsStore, 1000);
50 + }
51 +}, 500);
webui/js/messages.js
+16 -2
@@ -344,6 +344,12 @@ export function drawMessageUser(
344 img.src = getServerImageUrl(filename);
345 img.alt = filename;
346 img.classList.add("attachment-preview");
347 + img.style.cursor = "pointer";
348 + img.addEventListener('click', () => {
349 + if (window.Alpine && window.Alpine.store('chatAttachments')) {
350 + window.Alpine.store('chatAttachments').openImageModal(getServerImageUrl(filename), filename);
351 + }
352 + });
353
354 const fileInfo = document.createElement("div");
355 fileInfo.classList.add("file-info");
@@ -372,13 +378,21 @@ export function drawMessageUser(
378
379 const img = document.createElement("img");
380 // Use server URL if we have filename, otherwise fall back to blob URL for current session
381 + let imageUrl;
382 if (attachment.name && !attachment.url.startsWith('blob:')) {
376 - img.src = getServerImageUrl(attachment.name);
383 + imageUrl = getServerImageUrl(attachment.name);
384 } else {
378 - img.src = attachment.url;
385 + imageUrl = attachment.url;
386 }
387 + img.src = imageUrl;
388 img.alt = attachment.name;
389 img.classList.add("attachment-preview");
390 + img.style.cursor = "pointer";
391 + img.addEventListener('click', () => {
392 + if (window.Alpine && window.Alpine.store('chatAttachments')) {
393 + window.Alpine.store('chatAttachments').openImageModal(imageUrl, attachment.name);
394 + }
395 + });
396
397 const fileInfo = document.createElement("div");
398 fileInfo.classList.add("file-info");
webui/js/tunnel.js
+3 -4
@@ -5,6 +5,7 @@ document.addEventListener('alpine:init', () => {
5 tunnelLink: '',
6 linkGenerated: false,
7 loadingText: '',
8 + provider: 'serveo', // Default tunnel provider
9
10 init() {
11 this.checkTunnelStatus();
@@ -160,10 +161,8 @@ document.addEventListener('alpine:init', () => {
161 this.isLoading = true;
162 this.loadingText = 'Creating tunnel...';
163
163 - // Get provider from the parent settings modal scope
164 - const modalEl = document.getElementById('settingsModal');
165 - const modalAD = Alpine.$data(modalEl);
166 - const provider = modalAD.provider || 'serveo'; // Default to serveo if not set
164 + // Use the local provider setting
165 + const provider = this.provider || 'serveo'; // Default to serveo if not set
166
167 // Change create button appearance
168 const createButton = document.querySelector('.tunnel-actions .btn-ok');