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');