refactor: file-browser component

file browser component polishing

Alessandro committed Oct 14, 2025 at 14:39 UTC e7684b007cdb6d68b5602210a4328a32c8ee27a4
8 files changed +628 -663
webui/components/chat/input/bottom-actions.html
+4 -1
@@ -2,6 +2,9 @@
2 <head>
3 <script type="module">
4 import { store } from "/components/chat/input/input-store.js";
5 + import { store as historyStore } from "/components/modals/history-store.js";
6 + import { store as contextStore } from "/components/modals/context-store.js";
7 + import { store as fileBrowserStore } from "/components/modals/file-browser-store.js";
8 </script>
9 </head>
10 <body>
@@ -29,7 +32,7 @@
32 <p>Import knowledge</p>
33 </button>
34
32 - <button class="text-button" id="work_dir_browser" @click="fileBrowserModalProxy.openModal()">
35 + <button class="text-button" id="work_dir_browser" @click="$store.fileBrowser.open()">
36 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
37 <path d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="7"></path>
38 <path d="m6.38,20.23H1.75l7.03,67.03c.11,1.07.55,2.02,1.2,2.69.55.55,1.28.89,2.11.89h97.1c.82,0,1.51-.33,2.05-.87.68-.68,1.13-1.67,1.28-2.79l9.1-66.94H6.38Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="8"></path>
webui/components/modals/file-browser-store.js new
+244
@@ -0,0 +1,244 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { fetchApi } from "/js/api.js";
3 +
4 +// Model migrated from legacy file_browser.js (lift-and-shift)
5 +const model = {
6 + // Reactive state
7 + isLoading: false,
8 + browser: {
9 + title: "File Browser",
10 + currentPath: "",
11 + entries: [],
12 + parentPath: "",
13 + sortBy: "name",
14 + sortDirection: "asc",
15 + },
16 + history: [], // navigation stack
17 + initialPath: "", // Store path for open() call
18 + closePromise: null,
19 + error: null,
20 +
21 + // --- Lifecycle -----------------------------------------------------------
22 + init() {
23 + // Nothing special to do here; all methods available immediately
24 + },
25 +
26 + // --- Public API (called from button/link) --------------------------------
27 + async open() {
28 + if (this.isLoading) return; // Prevent double-open
29 +
30 + this.isLoading = true;
31 + this.error = null;
32 + this.history = [];
33 +
34 + try {
35 + // Open modal FIRST (immediate UI feedback)
36 + this.closePromise = window.openModal('modals/file-browser.html');
37 +
38 + // Setup cleanup on modal close
39 + if (this.closePromise && typeof this.closePromise.then === 'function') {
40 + this.closePromise.then(() => {
41 + this.destroy();
42 + });
43 + }
44 +
45 + // Use stored initial path or default
46 + const path = this.initialPath || this.browser.currentPath || "$WORK_DIR";
47 + this.browser.currentPath = path;
48 +
49 + // Fetch files
50 + await this.fetchFiles(this.browser.currentPath);
51 +
52 + } catch (error) {
53 + console.error("File browser error:", error);
54 + this.error = error?.message || "Failed to load files";
55 + this.isLoading = false;
56 + }
57 + },
58 +
59 + handleClose() {
60 + // Close the modal manually
61 + window.closeModal();
62 + },
63 +
64 + destroy() {
65 + // Reset state when modal closes
66 + this.isLoading = false;
67 + this.history = [];
68 + this.initialPath = "";
69 + this.browser.entries = [];
70 + },
71 +
72 + // --- Helpers -------------------------------------------------------------
73 + isArchive(filename) {
74 + const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
75 + const ext = filename.split(".").pop().toLowerCase();
76 + return archiveExts.includes(ext);
77 + },
78 +
79 + formatFileSize(size) {
80 + if (size === 0) return "0 Bytes";
81 + const k = 1024;
82 + const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
83 + const i = Math.floor(Math.log(size) / Math.log(k));
84 + return parseFloat((size / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
85 + },
86 +
87 + formatDate(dateString) {
88 + const options = {
89 + year: "numeric",
90 + month: "short",
91 + day: "numeric",
92 + hour: "2-digit",
93 + minute: "2-digit",
94 + };
95 + return new Date(dateString).toLocaleDateString(undefined, options);
96 + },
97 +
98 + // --- Sorting -------------------------------------------------------------
99 + toggleSort(column) {
100 + if (this.browser.sortBy === column) {
101 + this.browser.sortDirection =
102 + this.browser.sortDirection === "asc" ? "desc" : "asc";
103 + } else {
104 + this.browser.sortBy = column;
105 + this.browser.sortDirection = "asc";
106 + }
107 + },
108 +
109 + sortFiles(entries) {
110 + return [...entries].sort((a, b) => {
111 + // Folders first
112 + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
113 + const dir = this.browser.sortDirection === "asc" ? 1 : -1;
114 + switch (this.browser.sortBy) {
115 + case "name":
116 + return dir * a.name.localeCompare(b.name);
117 + case "size":
118 + return dir * (a.size - b.size);
119 + case "date":
120 + return dir * (new Date(a.modified) - new Date(b.modified));
121 + default:
122 + return 0;
123 + }
124 + });
125 + },
126 +
127 + // --- Navigation ----------------------------------------------------------
128 + async fetchFiles(path = "") {
129 + this.isLoading = true;
130 + try {
131 + const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(path)}`);
132 + if (response.ok) {
133 + const data = await response.json();
134 + this.browser.entries = data.data.entries;
135 + this.browser.currentPath = data.data.current_path;
136 + this.browser.parentPath = data.data.parent_path;
137 + } else {
138 + console.error("Error fetching files:", await response.text());
139 + this.browser.entries = [];
140 + }
141 + } catch (e) {
142 + window.toastFrontendError("Error fetching files: " + e.message, "File Browser Error");
143 + this.browser.entries = [];
144 + } finally {
145 + this.isLoading = false;
146 + }
147 + },
148 +
149 + async navigateToFolder(path) {
150 + if (this.browser.currentPath !== path) this.history.push(this.browser.currentPath);
151 + await this.fetchFiles(path);
152 + },
153 +
154 + async navigateUp() {
155 + if (this.browser.parentPath) {
156 + this.history.push(this.browser.currentPath);
157 + await this.fetchFiles(this.browser.parentPath);
158 + }
159 + },
160 +
161 + // --- File actions --------------------------------------------------------
162 + async deleteFile(file) {
163 + if (!confirm(`Are you sure you want to delete ${file.name}?`)) return;
164 + try {
165 + const resp = await fetchApi("/delete_work_dir_file", {
166 + method: "POST",
167 + headers: { "Content-Type": "application/json" },
168 + body: JSON.stringify({ path: file.path, currentPath: this.browser.currentPath }),
169 + });
170 + if (resp.ok) {
171 + this.browser.entries = this.browser.entries.filter((e) => e.path !== file.path);
172 + alert("File deleted successfully.");
173 + } else {
174 + alert(`Error deleting file: ${await resp.text()}`);
175 + }
176 + } catch (e) {
177 + window.toastFrontendError("Error deleting file: " + e.message, "File Delete Error");
178 + }
179 + },
180 +
181 + async handleFileUpload(event) {
182 + try {
183 + const files = event.target.files;
184 + if (!files.length) return;
185 + const formData = new FormData();
186 + formData.append("path", this.browser.currentPath);
187 + for (let f of files) {
188 + const ext = f.name.split(".").pop().toLowerCase();
189 + if (!["zip", "tar", "gz", "rar", "7z"].includes(ext) && f.size > 100 * 1024 * 1024) {
190 + alert(`File ${f.name} exceeds 100MB limit.`);
191 + continue;
192 + }
193 + formData.append("files[]", f);
194 + }
195 + const resp = await fetchApi("/upload_work_dir_files", { method: "POST", body: formData });
196 + if (resp.ok) {
197 + const data = await resp.json();
198 + this.browser.entries = data.data.entries;
199 + this.browser.currentPath = data.data.current_path;
200 + this.browser.parentPath = data.data.parent_path;
201 + if (data.failed && data.failed.length) {
202 + const msg = data.failed.map((f) => `${f.name}: ${f.error}`).join("\n");
203 + alert(`Some files failed to upload:\n${msg}`);
204 + }
205 + } else {
206 + alert(await resp.text());
207 + }
208 + } catch (e) {
209 + window.toastFrontendError("Error uploading files: " + e.message, "File Upload Error");
210 + } finally {
211 + event.target.value = ""; // reset input so same file can be reselected
212 + }
213 + },
214 +
215 + downloadFile(file) {
216 + const link = document.createElement("a");
217 + link.href = `/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
218 + link.download = file.name;
219 + document.body.appendChild(link);
220 + link.click();
221 + document.body.removeChild(link);
222 + },
223 +};
224 +
225 +export const store = createStore("fileBrowser", model);
226 +
227 +window.openFileLink = async function (path) {
228 + try {
229 + const resp = await window.sendJsonData("/file_info", { path });
230 + if (!resp.exists) {
231 + window.toastFrontendError("File does not exist.", "File Error");
232 + return;
233 + }
234 + if (resp.is_dir) {
235 + // Set initial path and open via store
236 + store.initialPath = resp.abs_path;
237 + await store.open();
238 + } else {
239 + store.downloadFile({ path: resp.abs_path, name: resp.file_name });
240 + }
241 + } catch (e) {
242 + window.toastFrontendError("Error opening file: " + e.message, "File Open Error");
243 + }
244 +};
webui/components/modals/file-browser.html new
+347
@@ -0,0 +1,347 @@
1 +<html>
2 +<head>
3 + <title>File Browser</title>
4 + <script type="module">
5 + import { store } from "/components/modals/file-browser-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.fileBrowser">
11 + <div class="file-browser-root">
12 +
13 + <!-- Loading State -->
14 + <div x-show="$store.fileBrowser.isLoading" class="loading-state">
15 + <div class="loading-spinner"></div>
16 + <p>Loading files...</p>
17 + </div>
18 +
19 + <!-- File Browser Content -->
20 + <div x-show="!$store.fileBrowser.isLoading" class="file-browser-content">
21 + <!-- Path navigator -->
22 + <div class="path-navigator">
23 + <button class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
24 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
25 + <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
26 + </svg>
27 + Up
28 + </button>
29 + <div id="current-path"><span id="path-text" x-text="$store.fileBrowser.browser.currentPath"></span></div>
30 + </div>
31 +
32 + <!-- Files list -->
33 + <div class="files-list">
34 + <div class="file-header">
35 + <div class="file-cell" @click="$store.fileBrowser.toggleSort('name')">Name <span x-show="$store.fileBrowser.browser.sortBy === 'name'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
36 + <div class="file-cell-size" @click="$store.fileBrowser.toggleSort('size')">Size <span x-show="$store.fileBrowser.browser.sortBy === 'size'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
37 + <div class="file-cell-date" @click="$store.fileBrowser.toggleSort('date')">Modified <span x-show="$store.fileBrowser.browser.sortBy === 'date'" x-text="$store.fileBrowser.browser.sortDirection === 'asc' ? '↑' : '↓'"></span></div>
38 + </div>
39 +
40 + <!-- File list entries -->
41 + <template x-if="$store.fileBrowser.browser.entries.length">
42 + <template x-for="file in $store.fileBrowser.sortFiles($store.fileBrowser.browser.entries)" :key="file.path">
43 + <div class="file-item" :data-is-dir="file.is_dir">
44 + <div class="file-name" @click="file.is_dir ? $store.fileBrowser.navigateToFolder(file.path) : $store.fileBrowser.downloadFile(file)">
45 + <img :src="'/public/' + (file.type === 'unknown' ? 'file' : ($store.fileBrowser.isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type" />
46 + <span x-text="file.name"></span>
47 + </div>
48 + <div class="file-size" x-text="$store.fileBrowser.formatFileSize(file.size)"></div>
49 + <div class="file-date" x-text="$store.fileBrowser.formatDate(file.modified)"></div>
50 + <div class="file-actions">
51 + <button class="action-button download-button" @click.stop="$store.fileBrowser.downloadFile(file)">
52 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.5 19.5"><path d="m.75,14.25v2.25c0,1.24,1.01,2.25,2.25,2.25h13.5c1.24,0,2.25-1.01,2.25-2.25v-2.25m-4.5-4.5l-4.5,4.5m0,0l-4.5-4.5m4.5,4.5V.75" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/></svg>
53 + </button>
54 + <button class="delete-button" @click.stop="$store.fileBrowser.deleteFile(file)">
55 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.03 22.53" fill="currentColor"><path d="m14.55,7.82H4.68L14.09,3.19c.83-.41,1.17-1.42.77-2.25-.41-.83-1.42-1.17-2.25-.77l-3.16,1.55-.15-.31c-.22-.44-.59-.76-1.05-.92-.46-.16-.96-.13-1.39.09l-2.08,1.02c-.9.44-1.28,1.54-.83,2.44l.15.31-3.16,1.55c-.83.41-1.17,1.42-.77,2.25.29.59.89.94,1.51.94.25,0,.5-.06.74-.17l.38-.19s.09.03.14.03h11.14v11.43c0,.76-.62,1.38-1.38,1.38h-.46v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-.46c-.76,0-1.38-.62-1.38-1.38v-9.9c0-.26-.21-.47-.47-.47s-.47.21-.47.47v9.9c0,1.28,1.04,2.32,2.32,2.32h8.55c1.28,0,2.32-1.04,2.32-2.32v-11.91c0-.26-.21-.47-.47-.47Z" stroke-width="0"/></svg>
56 + </button>
57 + </div>
58 + </div>
59 + </template>
60 + </template>
61 +
62 + <!-- Empty state -->
63 + <template x-if="!$store.fileBrowser.browser.entries.length">
64 + <div class="no-files">No files found</div>
65 + </template>
66 + </div>
67 + </div>
68 + </div>
69 +
70 + </div>
71 + </template>
72 +
73 + <!-- Modal Footer (outside template x-if so it exists immediately) -->
74 + <template x-if="$store.fileBrowser">
75 + <div class="modal-footer" data-modal-footer>
76 + <label class="btn btn-upload">
77 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
78 + Upload Files
79 + <input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
80 + </label>
81 + <button class="btn btn-cancel" @click="$store.fileBrowser.handleClose()">Close Browser</button>
82 + </div>
83 + </template>
84 + </div>
85 +
86 + <style>
87 + /* File Browser Root */
88 + .file-browser-root {
89 + display: flex;
90 + flex-direction: column;
91 + width: 100%;
92 + height: 100%;
93 + min-height: 400px;
94 + }
95 +
96 + /* Loading State */
97 + .loading-state {
98 + display: flex;
99 + flex-direction: column;
100 + align-items: center;
101 + justify-content: center;
102 + padding: var(--spacing-lg);
103 + min-height: 200px;
104 + }
105 +
106 + .loading-spinner {
107 + width: 40px;
108 + height: 40px;
109 + border: 3px solid var(--color-border);
110 + border-top: 3px solid var(--color-primary);
111 + border-radius: 50%;
112 + animation: spin 1s linear infinite;
113 + margin-bottom: var(--spacing-md);
114 + }
115 +
116 + @keyframes spin {
117 + 0% { transform: rotate(0deg); }
118 + 100% { transform: rotate(360deg); }
119 + }
120 +
121 + .loading-state p {
122 + color: var(--color-text-secondary);
123 + margin: 0;
124 + }
125 +
126 + /* File Browser Content */
127 + .file-browser-content {
128 + display: flex;
129 + flex-direction: column;
130 + padding: var(--spacing-sm) var(--spacing-sm);
131 + }
132 +
133 + /* File Browser Styles */
134 + .files-list,
135 + .file-header,
136 + .file-item {
137 + width: 100%;
138 + border-radius: 4px;
139 + overflow: hidden;
140 + }
141 +
142 + /* Header Styles */
143 + .file-header {
144 + display: grid;
145 + grid-template-columns: 2fr 0.6fr 1fr 80px;
146 + background: var(--secondary-bg);
147 + padding: 8px 0;
148 + font-weight: bold;
149 + border-bottom: 1px solid var(--border-color);
150 + color: var(--color-primary);
151 + }
152 + .file-cell,
153 + .file-cell-size,
154 + .file-cell-date {
155 + color: var(--color-primary);
156 + padding: 4px;
157 + cursor: pointer;
158 + }
159 +
160 + /* File Item Styles */
161 + .file-item {
162 + display: grid;
163 + grid-template-columns: 2fr 0.6fr 1fr 80px;
164 + align-items: center;
165 + padding: 8px 0;
166 + font-size: 0.875rem;
167 + border-top: 1px solid var(--color-border);
168 + transition: background-color 0.2s;
169 + white-space: nowrap;
170 + overflow: hidden;
171 + color: var(--color-text);
172 + }
173 + .file-item:hover {
174 + background-color: var(--color-secondary);
175 + }
176 +
177 + /* File Icon and Name */
178 + .file-icon {
179 + width: 1.8rem;
180 + height: 1.8rem;
181 + margin: 0 1rem 0 0.7rem;
182 + vertical-align: middle;
183 + font-size: var(--font-size-sm);
184 + }
185 + .file-name {
186 + display: flex;
187 + align-items: center;
188 + font-weight: 500;
189 + margin-right: var(--spacing-sm);
190 + overflow: hidden;
191 + }
192 + .file-name > span {
193 + white-space: nowrap;
194 + overflow: hidden;
195 + text-overflow: ellipsis;
196 + }
197 + .file-size,
198 + .file-date {
199 + color: var(--text-secondary);
200 + }
201 + /* No Files Message */
202 + .no-files {
203 + padding: 32px;
204 + text-align: center;
205 + color: var(--text-secondary);
206 + }
207 + /* Light Mode Adjustments */
208 + .light-mode .file-item:hover {
209 + background-color: var(--color-secondary-light);
210 + }
211 +
212 + /* Path Navigator Styles */
213 + .path-navigator {
214 + display: flex;
215 + align-items: center;
216 + gap: 24px;
217 + background-color: var(--color-message-bg);
218 + padding: 0.5rem var(--spacing-sm);
219 + margin: 0 0 var(--spacing-sm) 0;
220 + border: 1px solid var(--color-border);
221 + border-radius: 8px;
222 + }
223 + .nav-button {
224 + padding: 4px 12px;
225 + border: 1px solid var(--color-border);
226 + border-radius: 4px;
227 + background: var(--color-background);
228 + color: var(--color-text);
229 + cursor: pointer;
230 + transition: background-color 0.2s;
231 + }
232 + .nav-button:hover {
233 + background: var(--hover-bg);
234 + }
235 + .nav-button.back-button {
236 + background-color: var(--color-secondary);
237 + color: var(--color-text);
238 + }
239 + .nav-button.back-button:hover {
240 + background-color: var(--color-secondary-dark);
241 + }
242 + #current-path {
243 + opacity: 0.9;
244 + }
245 + #path-text {
246 + font-family: 'Roboto Mono', monospace;
247 + -webkit-font-optical-sizing: auto;
248 + font-optical-sizing: auto;
249 + opacity: 0.9;
250 + }
251 +
252 + /* Folder Specific Styles */
253 + .file-item[data-is-dir="true"] {
254 + cursor: pointer;
255 + }
256 + .file-item[data-is-dir="true"]:hover {
257 + background-color: var(--color-secondary);
258 + }
259 +
260 + /* Upload Button Styles */
261 + .btn-upload {
262 + display: inline-flex;
263 + align-items: center;
264 + padding: 8px 16px;
265 + background: #4248f1;
266 + gap: 0.5rem;
267 + color: white;
268 + border-radius: 4px;
269 + cursor: pointer;
270 + transition: background-color 0.3s ease-in-out;
271 + }
272 + .btn-upload > svg {
273 + width: 20px;
274 + }
275 + .btn-upload:hover {
276 + background-color: #353bc5;
277 + }
278 + .btn-upload:active {
279 + background-color: #2b309c;
280 + }
281 + /* Delete Button Styles */
282 + .delete-button {
283 + background: none;
284 + border: none;
285 + color: var(--color-primary);
286 + cursor: pointer;
287 + width: 32px;
288 + padding: 4px 8px;
289 + border-radius: 4px;
290 + transition: opacity 0.2s, background-color 0.2s;
291 + }
292 + .delete-button:hover {
293 + color: #ff7878;
294 + }
295 + .delete-button:active {
296 + opacity: 0.6;
297 + }
298 +
299 + /* File Actions */
300 + .file-actions {
301 + display: flex;
302 + gap: var(--spacing-xs);
303 + }
304 + .action-button {
305 + background: none;
306 + border: none;
307 + cursor: pointer;
308 + width: 32px;
309 + padding: 6px 8px;
310 + border-radius: 4px;
311 + transition: background-color 0.2s;
312 + }
313 + .download-button {
314 + color: var(--color-primary);
315 + }
316 + .download-button:hover {
317 + background-color: var(--color-border);
318 + }
319 + .light-mode .download-button:hover {
320 + background-color: #c6d4de;
321 + }
322 + /* Responsive Design */
323 + @media (max-width: 768px) {
324 + .file-header,
325 + .file-item {
326 + grid-template-columns: 1fr 0.5fr 80px;
327 + }
328 + .file-cell-date,
329 + .file-date {
330 + display: none;
331 + }
332 + }
333 + @media (max-width: 540px) {
334 + .file-header,
335 + .file-item {
336 + grid-template-columns: 1fr 80px;
337 + }
338 + .file-cell-size,
339 + .file-size,
340 + .file-cell-date,
341 + .file-date {
342 + display: none;
343 + }
344 + }
345 + </style>
346 +</body>
347 +</html>
webui/css/file_browser.css deleted
-249
@@ -1,249 +0,0 @@
1 -/* File Browser Styles */
2 -
3 -.files-list,
4 -.file-header,
5 -.file-item {
6 - width: 100%;
7 - border-radius: 4px;
8 - overflow: hidden;
9 -}
10 -
11 -/* Header Styles */
12 -.file-header {
13 - display: grid;
14 - grid-template-columns: 2fr 0.6fr 1fr 80px;
15 - background: var(--secondary-bg);
16 - padding: 8px 0;
17 - font-weight: bold;
18 - border-bottom: 1px solid var(--border-color);
19 - color: var(--color-primary);
20 -}
21 -
22 -.file-cell,
23 -.file-cell-size,
24 -.file-cell-date {
25 - color: var(--color-primary);
26 - padding: 4px;
27 - cursor: pointer;
28 -}
29 -
30 -/* File Item Styles */
31 -.file-item {
32 - display: grid;
33 - grid-template-columns: 2fr 0.6fr 1fr 80px;
34 - align-items: center;
35 - padding: 8px 0;
36 - font-size: 0.875rem;
37 - border-top: 1px solid var(--color-border);
38 - transition: background-color 0.2s;
39 - white-space: nowrap;
40 - overflow: hidden;
41 - color: var(--color-text);
42 -}
43 -
44 -.file-item:hover {
45 - background-color: var(--color-secondary);
46 -}
47 -
48 -/* File Icon and Name */
49 -.file-icon {
50 - width: 1.8rem;
51 - height: 1.8rem;
52 - margin: 0 1rem 0 0.7rem;
53 - vertical-align: middle;
54 - font-size: var(--font-size-sm);
55 -}
56 -
57 -.file-name {
58 - display: flex;
59 - align-items: center;
60 - font-weight: 500;
61 - margin-right: var(--spacing-sm);
62 - overflow: hidden;
63 -}
64 -
65 -.file-name > span {
66 - white-space: nowrap;
67 - overflow: hidden;
68 - text-overflow: ellipsis;
69 -}
70 -
71 -.file-size,
72 -.file-date {
73 - color: var(--text-secondary);
74 -}
75 -
76 -/* No Files Message */
77 -.no-files {
78 - padding: 32px;
79 - text-align: center;
80 - color: var(--text-secondary);
81 -}
82 -
83 -/* Light Mode Adjustments */
84 -.light-mode .file-item:hover {
85 - background-color: var(--color-secondary-light);
86 -}
87 -
88 -/* Path Navigator Styles */
89 -.path-navigator {
90 - display: flex;
91 - align-items: center;
92 - gap: 24px;
93 - background-color: var(--color-message-bg);
94 - padding: 0.5rem var(--spacing-sm);
95 - margin-bottom: 0.3rem;
96 - border: 1px solid var(--color-border);
97 - border-radius: 8px;
98 -}
99 -
100 -.nav-button {
101 - padding: 4px 12px;
102 - border: 1px solid var(--color-border);
103 - border-radius: 4px;
104 - background: var(--color-background);
105 - color: var(--color-text);
106 - cursor: pointer;
107 - transition: background-color 0.2s;
108 -}
109 -
110 -.nav-button:hover {
111 - background: var(--hover-bg);
112 -}
113 -
114 -.nav-button.back-button {
115 - background-color: var(--color-secondary);
116 - color: var(--color-text);
117 -}
118 -
119 -.nav-button.back-button:hover {
120 - background-color: var(--color-secondary-dark);
121 -}
122 -
123 -#current-path {
124 - opacity: 0.9;
125 -}
126 -
127 -#path-text {
128 - font-family: 'Roboto Mono', monospace;
129 - font-optical-sizing: auto;
130 - -webkit-font-optical-sizing: auto;
131 - opacity: 0.9;
132 -}
133 -
134 -/* Folder Specific Styles */
135 -.file-item[data-is-dir="true"] {
136 - cursor: pointer;
137 -}
138 -
139 -.file-item[data-is-dir="true"]:hover {
140 - background-color: var(--color-secondary);
141 -}
142 -
143 -/* Upload Button Styles */
144 -.upload-button,
145 -.btn-upload {
146 - display: inline-flex;
147 - align-items: center;
148 - padding: 8px 16px;
149 - background-color: var(--color-primary);
150 - color: white;
151 - border-radius: 4px;
152 - cursor: pointer;
153 - transition: background-color 0.3s ease-in-out;
154 -}
155 -
156 -.btn-upload {
157 - background: #4248f1;
158 - gap: 0.5rem;
159 - margin: 0 auto;
160 -}
161 -
162 -.btn-upload > svg {
163 - width: 20px;
164 -}
165 -
166 -.upload-button:hover,
167 -.btn-upload:hover {
168 - background-color: #353bc5;
169 -}
170 -
171 -.upload-button:active,
172 -.btn-upload:active {
173 - background-color: #2b309c;
174 -}
175 -
176 -/* Delete Button Styles */
177 -.delete-button {
178 - background: none;
179 - border: none;
180 - color: var(--color-primary);
181 - cursor: pointer;
182 - width: 32px;
183 - padding: 4px 8px;
184 - border-radius: 4px;
185 - transition: opacity 0.2s, background-color 0.2s;
186 -}
187 -
188 -.delete-button:hover {
189 - color: #ff7878;
190 -}
191 -
192 -.delete-button:active {
193 - opacity: 0.6;
194 -}
195 -
196 -/* File Actions */
197 -.file-actions {
198 - display: flex;
199 - gap: var(--spacing-xs);
200 -}
201 -
202 -.action-button {
203 - background: none;
204 - border: none;
205 - cursor: pointer;
206 - width: 32px;
207 - padding: 6px 8px;
208 - border-radius: 4px;
209 - transition: background-color 0.2s;
210 -}
211 -
212 -.download-button {
213 - color: var(--color-primary);
214 -}
215 -
216 -.download-button:hover {
217 - background-color: var(--color-border);
218 -}
219 -
220 -.light-mode .download-button:hover {
221 - background-color: #c6d4de;
222 -}
223 -
224 -/* Responsive Design */
225 -@media (max-width: 768px) {
226 - .file-header,
227 - .file-item {
228 - grid-template-columns: 1fr 0.5fr 80px;
229 - }
230 -
231 - .file-cell-date,
232 - .file-date {
233 - display: none;
234 - }
235 -}
236 -
237 -@media (max-width: 540px) {
238 - .file-header,
239 - .file-item {
240 - grid-template-columns: 1fr 80px;
241 - }
242 -
243 - .file-cell-size,
244 - .file-size,
245 - .file-cell-date,
246 - .file-date {
247 - display: none;
248 - }
249 -}
webui/css/modals2.css
+18
@@ -66,6 +66,24 @@ display: flex;
66 padding: 0 1rem 1rem 1rem;
67 }
68
69 +/* Modal with footer support */
70 +.modal-inner.modal-with-footer {
71 + display: flex;
72 + flex-direction: column;
73 +}
74 +
75 +.modal-inner.modal-with-footer .modal-scroll {
76 + flex: 1;
77 + min-height: 0;
78 + overflow-y: auto;
79 +}
80 +
81 +.modal-footer-slot {
82 + flex-shrink: 0;
83 + border-top: 1px solid var(--color-border);
84 + background: var(--color-background);
85 +}
86 +
87 .modal-x {
88 position: absolute;
89 top: 1rem;
webui/index.html
-145
@@ -11,7 +11,6 @@
11 <link rel="stylesheet" href="components/messages/action-buttons/simple-action-buttons.css">
12 <link rel="stylesheet" href="css/toast.css">
13 <link rel="stylesheet" href="css/settings.css">
14 - <link rel="stylesheet" href="css/file_browser.css">
14 <link rel="stylesheet" href="css/modals.css">
15 <link rel="stylesheet" href="css/modals2.css">
16 <link rel="stylesheet" href="css/speech.css">
@@ -1185,150 +1184,6 @@
1184 </template>
1185 </div>
1186
1188 - <!-- work_dir Browser Modal -->
1189 -
1190 - <div id="fileBrowserModal" x-data="fileBrowserModalProxy">
1191 - <template x-teleport="body">
1192 - <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()"
1193 - @keydown.escape.window="handleClose()" x-transition>
1194 - <div class="modal-container">
1195 - <div class="modal-header">
1196 - <h2 class="modal-title" x-text="browser.title"></h2>
1197 - <button class="modal-close" @click="handleClose()">&times;</button>
1198 - </div>
1199 - <div class="modal-content">
1200 - <div x-show="isLoading" class="loading-spinner">
1201 - Loading...
1202 - </div>
1203 - <div x-show="!isLoading">
1204 - <div class="path-navigator">
1205 - <!-- Up Button -->
1206 - <button class="text-button back-button" @click="navigateUp()" aria-label="Navigate Up">
1207 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
1208 - <path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none"
1209 - stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
1210 - stroke-width="1.5"></path>
1211 - </svg>
1212 - Up
1213 - </button>
1214 -
1215 - <div id="current-path">
1216 - <span id="path-text" x-text="browser.currentPath"></span>
1217 - </div>
1218 - </div>
1219 -
1220 - <div class="files-list">
1221 - <!-- Header -->
1222 - <div class="file-header">
1223 - <div class="file-cell" @click="toggleSort('name')">
1224 - Name
1225 - <span x-show="browser.sortBy === 'name'"
1226 - x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
1227 - </span>
1228 - </div>
1229 - <div class="file-cell-size" @click="toggleSort('size')">
1230 - Size
1231 - <span x-show="browser.sortBy === 'size'"
1232 - x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
1233 - </span>
1234 - </div>
1235 - <div class="file-cell-date" @click="toggleSort('date')">
1236 - Modified
1237 - <span x-show="browser.sortBy === 'date'"
1238 - x-text="browser.sortDirection === 'asc' ? '↑' : '↓'">
1239 - </span>
1240 - </div>
1241 - </div>
1242 -
1243 - <!-- File List -->
1244 - <template x-if="browser.entries.length">
1245 - <template x-for="file in sortFiles(browser.entries)" :key="file.path">
1246 - <div class="file-item" :data-is-dir="file.is_dir">
1247 - <div class="file-name"
1248 - @click="file.is_dir ? navigateToFolder(file.path) : downloadFile(file)">
1249 - <img :src="'/public/' + (file.type === 'unknown' ? 'file' : (isArchive(file.name) ? 'archive' : file.type)) + '.svg'"
1250 - class="file-icon" :alt="file.type">
1251 - <span x-text="file.name"></span>
1252 - </div>
1253 - <div class="file-size" x-text="formatFileSize(file.size)"></div>
1254 - <div class="file-date" x-text="formatDate(file.modified)"></div>
1255 -
1256 - <div class="file-actions">
1257 - <button class="action-button download-button"
1258 - @click.stop="downloadFile(file)">
1259 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.5 19.5">
1260 - <path
1261 - d="m.75,14.25v2.25c0,1.24,1.01,2.25,2.25,2.25h13.5c1.24,0,2.25-1.01,2.25-2.25v-2.25m-4.5-4.5l-4.5,4.5m0,0l-4.5-4.5m4.5,4.5V.75"
1262 - fill="none" stroke="currentColor" stroke-linecap="round"
1263 - stroke-linejoin="round" stroke-width="1.5"></path>
1264 - </svg>
1265 - </button>
1266 - <button class="delete-button" @click.stop="deleteFile(file)">
1267 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15.03 22.53"
1268 - fill="currentColor">
1269 - <path
1270 - d="m14.55,7.82H4.68L14.09,3.19c.83-.41,1.17-1.42.77-2.25-.41-.83-1.42-1.17-2.25-.77l-3.16,1.55-.15-.31c-.22-.44-.59-.76-1.05-.92-.46-.16-.96-.13-1.39.09l-2.08,1.02c-.9.44-1.28,1.54-.83,2.44l.15.31-3.16,1.55c-.83.41-1.17,1.42-.77,2.25.29.59.89.94,1.51.94.25,0,.5-.06.74-.17l.38-.19s.09.03.14.03h11.14v11.43c0,.76-.62,1.38-1.38,1.38h-.46v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-2.39v-11.28c0-.26-.21-.47-.47-.47s-.47.21-.47.47v11.28h-.46c-.76,0-1.38-.62-1.38-1.38v-9.9c0-.26-.21-.47-.47-.47s-.47.21-.47.47v9.9c0,1.28,1.04,2.32,2.32,2.32h8.55c1.28,0,2.32-1.04,2.32-2.32v-11.91c0-.26-.21-.47-.47-.47ZM5.19,2.46l2.08-1.02c.12-.06.25-.09.39-.09.09,0,.19.02.28.05.22.08.4.23.5.44l.15.31-.19.09-3.46,1.7-.15-.31c-.21-.43-.03-.96.4-1.17Zm-3.19,5.62c-.36.18-.8.03-.98-.33-.18-.36-.03-.8.33-.98l5.8-2.85,2.72-1.34,3.16-1.55c.1-.05.21-.07.32-.07.27,0,.53.15.66.41.09.17.1.37.04.56-.06.18-.19.33-.37.42L2,8.08Z"
1271 - stroke-width="0"></path>
1272 - </svg>
1273 - </button>
1274 - </div>
1275 - </div>
1276 - </template>
1277 - </template>
1278 -
1279 - <!-- Empty State -->
1280 - <template x-if="!browser.entries.length">
1281 - <div class="no-files">
1282 - No files found
1283 - </div>
1284 - </template>
1285 - </div>
1286 - </div>
1287 - </div>
1288 - <div class="modal-footer">
1289 - <div id="buttons-container">
1290 - <label class="btn btn-upload"><svg xmlns="http://www.w3.org/2000/svg" fill="none"
1291 - viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
1292 - <path stroke-linecap="round" stroke-linejoin="round"
1293 - d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
1294 - </path>
1295 - </svg>
1296 - Upload Files
1297 - <input type="file" multiple="" accept="all" @change="handleFileUpload"
1298 - style="display: none;">
1299 - </label>
1300 - <button class="btn btn-cancel" @click="handleClose()">Close Browser</button>
1301 - </div>
1302 - </div>
1303 - </div>
1304 - </div>
1305 - </template>
1306 - </div>
1307 -
1308 - <!-- generic modal -->
1309 -
1310 - <div id="genericModal" x-data="genericModalProxy">
1311 - <template x-teleport="body">
1312 - <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()"
1313 - @keydown.escape.window="handleClose()" x-transition>
1314 - <div class="modal-container">
1315 - <div class="modal-header">
1316 - <h2 class="modal-title" x-text="title"></h2>
1317 - <button class="modal-close" @click="handleClose()">&times;</button>
1318 - </div>
1319 - <div class="modal-description" x-text="description"></div>
1320 - <div class="modal-content" id="viewer">
1321 - <div class="html-pre" x-html="html"></div>
1322 - </div>
1323 - <!-- <div class="modal-footer">
1324 - <div id="buttons-container">
1325 - <button class="btn btn-cancel" @click="handleClose()">Close</button>
1326 - </div>
1327 - </div> -->
1328 - </div>
1329 - </template>
1330 - </div>
1331 -
1187 <!-- Full Screen Input Modal -->
1188 <x-component path="modals/full-screen-input.html"></x-component>
1189
webui/js/file_browser.js deleted
-268
@@ -1,268 +0,0 @@
1 -const fileBrowserModalProxy = {
2 - isOpen: false,
3 - isLoading: false,
4 -
5 - browser: {
6 - title: "File Browser",
7 - currentPath: "",
8 - entries: [],
9 - parentPath: "",
10 - sortBy: "name",
11 - sortDirection: "asc",
12 - },
13 -
14 - // Initialize navigation history
15 - history: [],
16 -
17 - async openModal(path) {
18 - const modalEl = document.getElementById("fileBrowserModal");
19 - const modalAD = Alpine.$data(modalEl);
20 -
21 - modalAD.isOpen = true;
22 - modalAD.isLoading = true;
23 - modalAD.history = []; // reset history when opening modal
24 -
25 - // Initialize currentPath to root if it's empty
26 - if (path) modalAD.browser.currentPath = path;
27 - else if (!modalAD.browser.currentPath)
28 - modalAD.browser.currentPath = "$WORK_DIR";
29 -
30 - await modalAD.fetchFiles(modalAD.browser.currentPath);
31 - },
32 -
33 - isArchive(filename) {
34 - const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
35 - const ext = filename.split(".").pop().toLowerCase();
36 - return archiveExts.includes(ext);
37 - },
38 -
39 - async fetchFiles(path = "") {
40 - this.isLoading = true;
41 - try {
42 - const response = await fetchApi(
43 - `/get_work_dir_files?path=${encodeURIComponent(path)}`
44 - );
45 -
46 - if (response.ok) {
47 - const data = await response.json();
48 - this.browser.entries = data.data.entries;
49 - this.browser.currentPath = data.data.current_path;
50 - this.browser.parentPath = data.data.parent_path;
51 - } else {
52 - console.error("Error fetching files:", await response.text());
53 - this.browser.entries = [];
54 - }
55 - } catch (error) {
56 - window.toastFrontendError("Error fetching files: " + error.message, "File Browser Error");
57 - this.browser.entries = [];
58 - } finally {
59 - this.isLoading = false;
60 - }
61 - },
62 -
63 - async navigateToFolder(path) {
64 - // Push current path to history before navigating
65 - if (this.browser.currentPath !== path) {
66 - this.history.push(this.browser.currentPath);
67 - }
68 - await this.fetchFiles(path);
69 - },
70 -
71 - async navigateUp() {
72 - if (this.browser.parentPath !== "") {
73 - // Push current path to history before navigating up
74 - this.history.push(this.browser.currentPath);
75 - await this.fetchFiles(this.browser.parentPath);
76 - }
77 - },
78 -
79 - sortFiles(entries) {
80 - return [...entries].sort((a, b) => {
81 - // Folders always come first
82 - if (a.is_dir !== b.is_dir) {
83 - return a.is_dir ? -1 : 1;
84 - }
85 -
86 - const direction = this.browser.sortDirection === "asc" ? 1 : -1;
87 - switch (this.browser.sortBy) {
88 - case "name":
89 - return direction * a.name.localeCompare(b.name);
90 - case "size":
91 - return direction * (a.size - b.size);
92 - case "date":
93 - return direction * (new Date(a.modified) - new Date(b.modified));
94 - default:
95 - return 0;
96 - }
97 - });
98 - },
99 -
100 - toggleSort(column) {
101 - if (this.browser.sortBy === column) {
102 - this.browser.sortDirection =
103 - this.browser.sortDirection === "asc" ? "desc" : "asc";
104 - } else {
105 - this.browser.sortBy = column;
106 - this.browser.sortDirection = "asc";
107 - }
108 - },
109 -
110 - async deleteFile(file) {
111 - if (!confirm(`Are you sure you want to delete ${file.name}?`)) {
112 - return;
113 - }
114 -
115 - try {
116 - const response = await fetchApi("/delete_work_dir_file", {
117 - method: "POST",
118 - headers: {
119 - "Content-Type": "application/json",
120 - },
121 - body: JSON.stringify({
122 - path: file.path,
123 - currentPath: this.browser.currentPath,
124 - }),
125 - });
126 -
127 - if (response.ok) {
128 - const data = await response.json();
129 - this.browser.entries = this.browser.entries.filter(
130 - (entry) => entry.path !== file.path
131 - );
132 - alert("File deleted successfully.");
133 - } else {
134 - alert(`Error deleting file: ${await response.text()}`);
135 - }
136 - } catch (error) {
137 - window.toastFrontendError("Error deleting file: " + error.message, "File Delete Error");
138 - alert("Error deleting file");
139 - }
140 - },
141 -
142 - async handleFileUpload(event) {
143 - try {
144 - const files = event.target.files;
145 - if (!files.length) return;
146 -
147 - const formData = new FormData();
148 - formData.append("path", this.browser.currentPath);
149 -
150 - for (let i = 0; i < files.length; i++) {
151 - const ext = files[i].name.split(".").pop().toLowerCase();
152 - if (!["zip", "tar", "gz", "rar", "7z"].includes(ext)) {
153 - if (files[i].size > 100 * 1024 * 1024) {
154 - // 100MB
155 - alert(
156 - `File ${files[i].name} exceeds the maximum allowed size of 100MB.`
157 - );
158 - continue;
159 - }
160 - }
161 - formData.append("files[]", files[i]);
162 - }
163 -
164 - // Proceed with upload after validation
165 - const response = await fetchApi("/upload_work_dir_files", {
166 - method: "POST",
167 - body: formData,
168 - });
169 -
170 - if (response.ok) {
171 - const data = await response.json();
172 - // Update the file list with new data
173 - this.browser.entries = data.data.entries.map((entry) => ({
174 - ...entry,
175 - uploadStatus: data.failed.includes(entry.name) ? "failed" : "success",
176 - }));
177 - this.browser.currentPath = data.data.current_path;
178 - this.browser.parentPath = data.data.parent_path;
179 -
180 - // Show success message
181 - if (data.failed && data.failed.length > 0) {
182 - const failedFiles = data.failed
183 - .map((file) => `${file.name}: ${file.error}`)
184 - .join("\n");
185 - alert(`Some files failed to upload:\n${failedFiles}`);
186 - }
187 - } else {
188 - alert(data.message);
189 - }
190 - } catch (error) {
191 - window.toastFrontendError("Error uploading files: " + error.message, "File Upload Error");
192 - alert("Error uploading files");
193 - }
194 - },
195 -
196 - downloadFile(file) {
197 - const link = document.createElement("a");
198 - link.href = `/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
199 - link.download = file.name;
200 - document.body.appendChild(link);
201 - link.click();
202 - document.body.removeChild(link);
203 - },
204 -
205 - // Helper Functions
206 - formatFileSize(size) {
207 - if (size === 0) return "0 Bytes";
208 - const k = 1024;
209 - const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
210 - const i = Math.floor(Math.log(size) / Math.log(k));
211 - return parseFloat((size / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
212 - },
213 -
214 - formatDate(dateString) {
215 - const options = {
216 - year: "numeric",
217 - month: "short",
218 - day: "numeric",
219 - hour: "2-digit",
220 - minute: "2-digit",
221 - };
222 - return new Date(dateString).toLocaleDateString(undefined, options);
223 - },
224 -
225 - handleClose() {
226 - this.isOpen = false;
227 - },
228 -};
229 -
230 -// Wait for Alpine to be ready
231 -document.addEventListener("alpine:init", () => {
232 - Alpine.data("fileBrowserModalProxy", () => ({
233 - init() {
234 - Object.assign(this, fileBrowserModalProxy);
235 - // Ensure immediate file fetch when modal opens
236 - this.$watch("isOpen", async (value) => {
237 - if (value) {
238 - await this.fetchFiles(this.browser.currentPath);
239 - }
240 - });
241 - },
242 - }));
243 -});
244 -
245 -// Keep the global assignment for backward compatibility
246 -window.fileBrowserModalProxy = fileBrowserModalProxy;
247 -
248 -openFileLink = async function (path) {
249 - try {
250 - const resp = await window.sendJsonData("/file_info", { path });
251 - if (!resp.exists) {
252 - window.toastFrontendError("File does not exist.", "File Error");
253 - return;
254 - }
255 -
256 - if (resp.is_dir) {
257 - fileBrowserModalProxy.openModal(resp.abs_path);
258 - } else {
259 - fileBrowserModalProxy.downloadFile({
260 - path: resp.abs_path,
261 - name: resp.file_name,
262 - });
263 - }
264 - } catch (e) {
265 - window.toastFrontendError("Error opening file: " + e.message, "File Open Error");
266 - }
267 -};
268 -window.openFileLink = openFileLink;
webui/js/modals.js
+15
@@ -74,6 +74,7 @@ function createModalElement(name) {
74 <div class="modal-scroll">
75 <div class="modal-bd"></div>
76 </div>
77 + <div class="modal-footer-slot" style="display: none;"></div>
78 </div>
79 `;
80
@@ -96,6 +97,8 @@ function createModalElement(name) {
97 title: newModal.querySelector(".modal-title"),
98 body: newModal.querySelector(".modal-bd"),
99 close: close_button,
100 + footerSlot: newModal.querySelector(".modal-footer-slot"),
101 + inner: newModal.querySelector(".modal-inner"),
102 styles: [],
103 scripts: [],
104 };
@@ -135,6 +138,18 @@ export function openModal(modalPath) {
138 if (doc.body && doc.body.classList) {
139 modal.body.classList.add(...doc.body.classList);
140 }
141 +
142 + // Some modals have a footer. Check if it exists and move it to footer slot
143 + // Use requestAnimationFrame to let Alpine mount the component first
144 + requestAnimationFrame(() => {
145 + const componentFooter = modal.body.querySelector('[data-modal-footer]');
146 + if (componentFooter && modal.footerSlot) {
147 + // Move footer outside modal-scroll scrollable area
148 + modal.footerSlot.appendChild(componentFooter);
149 + modal.footerSlot.style.display = 'block';
150 + modal.inner.classList.add('modal-with-footer');
151 + }
152 + });
153 })
154 .catch((error) => {
155 console.error("Error loading modal content:", error);